Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions cyclops/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions cyclops/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions cyclops/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down
30 changes: 30 additions & 0 deletions cyclops/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <root>/htdocs.
func serverRootedAt(root string) *ModCyclopsServer {
Expand Down
13 changes: 13 additions & 0 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@
"cyclops.sets.remove-tags"
]
},
{
"methods": [
"POST"
],
"pathPattern": "/cyclops/sets/{setName}/{recordId}",
"permissionsRequired": [
"cyclops.sets.spectre.update"
]
},
{
"methods": [
"GET"
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions htdocs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@
<li><a href="/cyclops/sets/korea_lit.mike?fields=%2A&cond=143100000%20%3C%3D%20age%20AND%20age%20%3C%3D%20201400000&filter=jurassic&tag=dino,ptero&sort=author,title&offset=20&limit=10">retrieve from set "korea_lit.mike"</a></li>
<li><a href="/cyclops/sets/korea_lit.mike?fields=%2A&cond=title='rings' or author='lewis'&sort=author&offset=20&limit=10">retrieve from set "korea_lit.mike" using only supported params</a></li>
<li><a href="/cyclops/sets/korea_lit.mike?fields=%2A">retrieve everything from set "korea_lit.mike"</a></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/add', { from: 'reserve', cond: `author = 'Adams, John'` })">add objects to set "korea_lit.mike"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/remove', { cond: 'id = 55017' })">remove item 55017 from set "korea_lit.mike"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/add', { from: 'korea_lit.object', cond: `author = 'Adams, John'` })">add objects to set "korea_lit.mike"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/remove', { cond: 'id = 55017' })">remove spectre 55017 from set "korea_lit.mike"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/282837', { decision: true, fund: 'palci_cultural' })">set decision=true, fund=palci_cultural in spectre 282837</button></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/tag/dino', { op: 'add', cond: 'title LIKE %saur', filter: 'jurassic' })">add tag "dino" to records in set "korea_lit.mike"</button></li>
<li><button class="link-button" onclick="postData('/cyclops/sets/korea_lit.mike/tag/dino', { op: 'remove', cond: 'author LIKE %taylor%', filter: 'cretaceous' })">remove tag "dino" from records in set "korea_lit.mike"</button></li>
</ul>
Expand Down
2 changes: 2 additions & 0 deletions ramls/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions ramls/cyclops.raml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions ramls/examples/spectre-example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"decision": true,
"fund": "palci"
}
16 changes: 16 additions & 0 deletions ramls/spectre-schema.json
Original file line number Diff line number Diff line change
@@ -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
}