diff --git a/TODO.md b/TODO.md index 43c6337..0c65023 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,7 @@ In Mod-Cyclops, for the moment it would be sufficient to replace `reserve` in th * The `reserve` set has been superseded by the `object` set in each project. * Added the `update` command for changing `object` attributes. -* Added the `show sets in project` command. +* **DONE** Added the `show sets in project` command. * **DONE** The project property `locations` has been removed. * Added the `archive project` command, to be used instead of `drop project`. * The `drop project` command now only drops archived projects. diff --git a/cyclops/handlers.go b/cyclops/handlers.go index 54cecd1..4896370 100644 --- a/cyclops/handlers.go +++ b/cyclops/handlers.go @@ -424,6 +424,43 @@ func (server *ModCyclopsServer) handleRemoveObjects(w http.ResponseWriter, req * // ----------------------------------------------------------------------------- +type UpdateRecord struct { + Decision bool `json:"decision"` + Fund string `json:"fund"` +} + +func (server *ModCyclopsServer) handleUpdateRecord(w http.ResponseWriter, req *http.Request, caption string) error { + setName := chi.URLParam(req, "setName") + recordId := chi.URLParam(req, "recordId") + + var record UpdateRecord + err := unmarshalBody(req, &record) + if err != nil { + return fmt.Errorf("%s: %w", caption, err) + } + + // A qualified set name such as "foo.bar" refers to the objects within the + // set, which CCMS addresses as "foo.object". + prefix, _, found := strings.Cut(setName, ".") + if found { + setName = prefix + ".object" + } + + command := fmt.Sprintf("update %s set decision = %v where id = %s; update %s set fund = %s where id = %s;", + setName, record.Decision, recordId, setName, record.Fund, recordId) + server.Log("command", command) + + _, err = server.sendToCCMS(caption+" "+setName+"/"+recordId, command) + if err != nil { + return err + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// ----------------------------------------------------------------------------- + func (server *ModCyclopsServer) handleAddRemoveTags(w http.ResponseWriter, req *http.Request, caption string) error { // It seems weird to just shrug and say "fine" for anything posted, but for now it will suffice. w.WriteHeader(http.StatusNoContent) diff --git a/cyclops/handlers_test.go b/cyclops/handlers_test.go index 7599069..877fec4 100644 --- a/cyclops/handlers_test.go +++ b/cyclops/handlers_test.go @@ -417,6 +417,42 @@ func TestHandleDefineTag(t *testing.T) { }) } +func TestHandleUpdateRecord(t *testing.T) { + t.Run("simple case with non-faceted set-name", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + params := map[string]string{"setName": "mike", "recordId": "rec1"} + rr := httptest.NewRecorder() + err := server.handleUpdateRecord(rr, jsonRequest(`{"decision":true,"fund":"palci"}`, params), "update record") + if err != nil { + t.Fatalf("handleUpdateRecord returned error: %v", err) + } + + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1;") + assertStatus(t, rr, http.StatusNoContent) + }) + + // A qualified set name like "foo.bar" has the part after the period replaced + // with "object" before the command is built. + t.Run("complex case with faceted set-name", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + params := map[string]string{"setName": "foo.bar", "recordId": "rec1"} + rr := httptest.NewRecorder() + err := server.handleUpdateRecord(rr, jsonRequest(`{"decision":true,"fund":"palci"}`, params), "update record") + if err != nil { + t.Fatalf("handleUpdateRecord returned error: %v", err) + } + + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update foo.object set decision = true where id = rec1; update foo.object set fund = palci where id = rec1;") + assertStatus(t, rr, http.StatusNoContent) + }) +} + func TestHandleDefineFilter(t *testing.T) { fake := &fakeCCMS{resp: okResponse()} server := newTestServer(fake) diff --git a/cyclops/server.go b/cyclops/server.go index 5a1f83e..3c12136 100644 --- a/cyclops/server.go +++ b/cyclops/server.go @@ -99,6 +99,9 @@ func MakeModCyclopsServer(logger *catlogger.Logger, ccmsClient CCMSClient, root r.Post("/cyclops/sets/{setName}/tag/{tagName}", func(w http.ResponseWriter, req *http.Request) { server.runWithErrorHandling(w, req, server.handleAddRemoveTags, "add/remove tags") }) + r.Post("/cyclops/sets/{setName}/{recordId}", func(w http.ResponseWriter, req *http.Request) { + server.runWithErrorHandling(w, req, server.handleUpdateRecord, "update record") + }) r.Get("/cyclops/projects", func(w http.ResponseWriter, req *http.Request) { server.runWithErrorHandling(w, req, server.handleShowProjects, "show projects") }) diff --git a/cyclops/server_test.go b/cyclops/server_test.go index 4a984f5..d667873 100644 --- a/cyclops/server_test.go +++ b/cyclops/server_test.go @@ -6,6 +6,7 @@ import "net/http/httptest" import "os" import "path/filepath" import "reflect" +import "strings" import "testing" import "github.com/MikeTaylor/catlogger" import "github.com/indexdata/ccms" @@ -90,6 +91,35 @@ func TestMakeModCyclopsServerRoutesToHandler(t *testing.T) { } } +// The POST /cyclops/sets/{setName}/{recordId} route is a wildcard sibling of +// the static /add, /remove and /tag sub-routes. Confirm the constructor wires +// it to handleUpdateRecord, and that the static routes still take precedence. +func TestMakeModCyclopsServerUpdateRecordRoute(t *testing.T) { + t.Run("dispatches to the update-record handler", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + body := strings.NewReader(`{"decision":true,"fund":"palci"}`) + rr := serve(server, httptest.NewRequest(http.MethodPost, "/cyclops/sets/mike/rec1", body)) + + assertStatus(t, rr, http.StatusNoContent) + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1;") + }) + + t.Run("static /add still wins over {recordId}", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + body := strings.NewReader(`{"from":"src"}`) + rr := serve(server, httptest.NewRequest(http.MethodPost, "/cyclops/sets/mike/add", body)) + + assertStatus(t, rr, http.StatusNoContent) + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "insert into mike select * from src limit 100;") + }) +} + // serverRootedAt builds a server whose static files are served from the given // directory. The htdocs FileServer reads from /htdocs. func serverRootedAt(root string) *ModCyclopsServer { diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 63d7b96..ccfb289 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -116,6 +116,15 @@ "cyclops.sets.remove-tags" ] }, + { + "methods": [ + "POST" + ], + "pathPattern": "/cyclops/sets/{setName}/{recordId}", + "permissionsRequired": [ + "cyclops.sets.spectre.update" + ] + }, { "methods": [ "GET" @@ -247,6 +256,10 @@ "displayName": "CYCLOPS: Remove tags from objects in a set (UNIMPLEMENTED)", "subPermissions": ["cyclops.sets.modify-tags"] }, + { + "permissionName": "cyclops.sets.spectre.update", + "displayName": "CYCLOPS: Update a record (spectre) within a set" + }, { "permissionName": "cyclops.projects.collection.get", "displayName": "CYCLOPS: Read list of projects" diff --git a/htdocs/index.html b/htdocs/index.html index 5e1386f..44b6c33 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -75,8 +75,9 @@
  • retrieve from set "korea_lit.mike"
  • retrieve from set "korea_lit.mike" using only supported params
  • retrieve everything from set "korea_lit.mike"
  • -
  • -
  • +
  • +
  • +
  • diff --git a/ramls/Makefile b/ramls/Makefile index 8910bc1..b5f806e 100644 --- a/ramls/Makefile +++ b/ramls/Makefile @@ -22,6 +22,7 @@ schemalint: z-schema addobjects-schema.json z-schema removeobjects-schema.json z-schema addremovetag-schema.json + z-schema spectre-schema.json z-schema location-schema.json z-schema project-schema.json z-schema projects-schema.json @@ -40,6 +41,7 @@ examplelint: z-schema removeobjects-schema.json examples/removeobject-example.json z-schema addremovetag-schema.json examples/addtag-example.json z-schema addremovetag-schema.json examples/removetag-example.json + z-schema spectre-schema.json examples/spectre-example.json z-schema location-schema.json examples/location-example.json z-schema project-schema.json examples/project-example.json z-schema projects-schema.json examples/show-projects-example.json diff --git a/ramls/cyclops.raml b/ramls/cyclops.raml index 95a2ef1..65d0085 100644 --- a/ramls/cyclops.raml +++ b/ramls/cyclops.raml @@ -165,6 +165,21 @@ documentation: responses: 204: description: No content is returned. + /{recordId}: + uriParameters: + recordId: + description: The identifier of the record within the set to update + type: string + required: true + post: + description: "Update the fields of a single record within a set" + body: + application/json: + type: !include spectre-schema.json + example: !include examples/spectre-example.json + responses: + 204: + description: No content is returned. /projects: description: "The projects in the system, in which collaborative collection management takes place" get: diff --git a/ramls/examples/spectre-example.json b/ramls/examples/spectre-example.json new file mode 100644 index 0000000..bfc6791 --- /dev/null +++ b/ramls/examples/spectre-example.json @@ -0,0 +1,4 @@ +{ + "decision": true, + "fund": "palci" +} diff --git a/ramls/spectre-schema.json b/ramls/spectre-schema.json new file mode 100644 index 0000000..bbef594 --- /dev/null +++ b/ramls/spectre-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "A request to update the fields of a single record within a set", + "type": "object", + "properties": { + "decision": { + "type": "boolean", + "description": "Whether the record has been approved" + }, + "fund": { + "type": "string", + "description": "The name of the fund associated with the record" + } + }, + "additionalProperties": false +}