Skip to content

Commit

Permalink
Merge pull request #86 from zazuko/property-operations-responses
Browse files Browse the repository at this point in the history
improvements to operation middleware
  • Loading branch information
tpluscode authored Dec 1, 2020
2 parents 825c95b + ff22a44 commit 14cdcee
Show file tree
Hide file tree
Showing 15 changed files with 265 additions and 125 deletions.
2 changes: 1 addition & 1 deletion StoreResourceLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class StoreResourceLoader {

for (const quad of dataset) {
result.push({
property: quad.property,
property: quad.predicate,
object: quad.object,
...await this.load(quad.subject)
})
Expand Down
27 changes: 27 additions & 0 deletions examples/blog/api.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,30 @@
<comments> a hydra:Link;
hydra:supportedOperation
<comment#post>.

<Category> a hydra:Class ;
hydra:supportedOperation
<category#get>;
hydra:supportedProperty [
hydra:property <post>
] ;
hydra:supportedProperty [
hydra:property <pinned-post>
]
.

<category#get> a hydra:SupportedOperation;
hydra:method "GET";
hydra:returns <Category>;
code:implementedBy [ a code:EcmaScript;
code:link <file:./category.js#get>
].

<post> a hydra:Link, rdf:Property .

<pinned-post> a hydra:Link, rdf:Property ;
hydra:supportedOperation [
hydra:method "PUT" ;
code:implementedBy "(req, res, next) => res.status(204).end()"^^code:EcmaScript
]
.
7 changes: 7 additions & 0 deletions examples/blog/category.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
function get (req, res) {
res.dataset(req.hydra.resource.dataset)
}

module.exports = {
get
}
3 changes: 3 additions & 0 deletions examples/blog/store-default/category%2Fcsvw.nt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<http://localhost:9000/category/csvw> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://localhost:9000/api/schema/Category> .

<http://localhost:9000/category/csvw> <http://localhost:9000/api/schema/post> <http://localhost:9000/post/2> .
3 changes: 3 additions & 0 deletions examples/blog/store-default/category%2Fknowledge-graphs.nt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<http://localhost:9000/category/knowledge-graphs> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://localhost:9000/api/schema/Category> .

<http://localhost:9000/category/knowledge-graphs> <http://localhost:9000/api/schema/pinned-post> <http://localhost:9000/post/3> .
3 changes: 3 additions & 0 deletions examples/blog/store-default/category%2Frdf.nt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<http://localhost:9000/category/rdf> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://localhost:9000/api/schema/Category> .

<http://localhost:9000/category/rdf> <http://localhost:9000/api/schema/post> <http://localhost:9000/post/3> .
124 changes: 86 additions & 38 deletions lib/middleware/operation.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { error, warn } = require('../log')('operation')
const { Router } = require('express')
const { error, warn, debug } = require('../log')('operation')
const createError = require('http-errors')
const clownface = require('clownface')
const ns = require('@tpluscode/rdf-ns-builders')
Expand All @@ -14,9 +15,12 @@ function findClassOperations (types, method) {
return operations
}

function findPropertyOperations ({ types, method, term, resource }) {
function findPropertyOperations ({ resource, api, method }) {
const apiGraph = clownface(api)
const types = apiGraph.node([...resource.types])

const properties = [...resource.dataset
.match(resource.term, null, term)]
.match(resource.term, resource.property, resource.object)]
.map(({ predicate }) => predicate)

let operations = types
Expand All @@ -32,55 +36,99 @@ function findPropertyOperations ({ types, method, term, resource }) {
return operations
}

function factory (api) {
return async (req, res, next) => {
if (!req.hydra.resource) {
return next()
function mapOperations ({ api, res, method }) {
return res.locals.hydra.resources.flatMap((resource) => {
let moreOperations
if ('property' in resource) {
moreOperations = findPropertyOperations({ resource, method, api }).toArray()
} else {
const types = clownface({ ...api, term: [...resource.types] })
moreOperations = findClassOperations(types, method).toArray()
}

const method = req.method === 'HEAD' ? 'GET' : req.method
const types = clownface({ ...api, term: [...req.hydra.resource.types] })

let operations
if (req.hydra.term.equals(req.hydra.resource.term)) {
// only look for direct operation when the root resource is requested
operations = findClassOperations(types, method)
}
if (!operations) {
// otherwise try finding the operation by property usage
operations = findPropertyOperations({ types, method, term: req.hydra.term, resource: req.hydra.resource })
if (moreOperations.length) {
return moreOperations.map(operation => ({ resource, operation }))
}

const [operation, ...rest] = (operations || []).toArray()
return [{ resource, operation: null }]
})
}

function findOperations (req, res, next) {
const method = req.method === 'HEAD' ? 'GET' : req.method

req.hydra.operations = mapOperations({ api: req.hydra.api, res, method })
.filter(({ operation }) => operation)

return next()
}

function prepareOperation (req, res, next) {
if (!req.hydra.operations.length) {
return next()
}

if (req.hydra.operations.length > 1) {
error('Multiple operations found')
return next(new Error('Ambiguous operation'))
}

const [{ resource, operation }] = req.hydra.operations
req.hydra.resource = resource
req.hydra.operation = operation
return next()
}

async function invokeOperation (req, res, next) {
const { api } = req.hydra

if (!operation) {
if (!req.hydra.operation) {
const operationMap = mapOperations({ api, res })
const allowedMethods = new Set(operationMap
.filter(({ operation }) => operation)
.flatMap(({ operation }) => operation.out(ns.hydra.method).values))

if (!allowedMethods.size) {
warn('no operations found')
let allowedOperations
if (req.hydra.term.equals(req.hydra.resource.term)) {
allowedOperations = findClassOperations(types)
} else {
allowedOperations = findPropertyOperations({ types, term: req.hydra.term, resource: req.hydra.resource })
if (operationMap.every(({ resource }) => 'property' in resource)) {
return next(new createError.NotFound())
}

const allowedMethods = new Set(allowedOperations.out(ns.hydra.method).values)
res.setHeader('Allow', [...allowedMethods])
return next(new createError.MethodNotAllowed())
}

if (rest.length > 0) {
error('Multiple operations found')
return next(new Error('Ambiguous operation'))
}
res.setHeader('Allow', [...allowedMethods])
return next(new createError.MethodNotAllowed())
}

const handler = await api.loaderRegistry.load(operation.out(code.implementedBy), { basePath: api.codePath })
debug(`IRI: ${req.hydra.resource.term.value}`)
debug(`types: ${[...req.hydra.resource.types].map(term => term.value).join(' ')}`)

if (!handler) {
return next(new Error('Failed to load operation'))
}
const handler = await api.loaderRegistry.load(req.hydra.operation.out(code.implementedBy), { basePath: api.codePath })

req.hydra.operation = operation
handler(req, res, next)
if (!handler) {
return next(new Error('Failed to load operation'))
}

handler(req, res, next)
}

function factory (middleware = {}) {
const router = Router()

router.use(findOperations)

if (middleware.operations) {
router.use(middleware.operations)
}

router.use(prepareOperation)

if (middleware.resource) {
router.use(middleware.resource)
}
router.use(invokeOperation)

return router
}

module.exports = factory
27 changes: 11 additions & 16 deletions lib/middleware/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,21 @@ const { debug } = require('../log')('resource')

function factory ({ loader }) {
return async (req, res, next) => {
let resources = await loader.forClassOperation(req.hydra.term)
const classResources = await loader.forClassOperation(req.hydra.term)
const propertyObjectResources = await loader.forPropertyOperation(req.hydra.term)

if (resources.length === 0) {
resources = await loader.forPropertyOperation(req.hydra.term)
}

if (resources.length > 1) {
return next(new Error(`no unique resource found for: <${req.hydra.term.value}>`))
}

req.hydra.resource = resources[0]

if (req.hydra.resource) {
debug(`IRI: ${req.hydra.resource.term.value}`)
debug(`types: ${[...req.hydra.resource.types].map(term => term.value).join(' ')}`)
} else {
res.locals.hydra.resources = [
...classResources,
...propertyObjectResources
]
if (res.locals.hydra.resources.length === 0) {
debug(`no matching resource found: ${req.hydra.term.value}`)
}
if (res.locals.hydra.resources.length > 1) {
debug('multiple resource candidates found')
}

next()
return next()
}
}

Expand Down
10 changes: 6 additions & 4 deletions middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ function middleware (api, { baseIriFromRequest, loader, store, middleware = {} }
router.use(waitFor(init, () => apiHeader(api)))
router.use(waitFor(init, () => iriTemplate(api)))

router.use((req, res, next) => {
res.locals.hydra = {}
next()
})

if (loader) {
router.use(resource({ loader }))
} else if (store) {
Expand All @@ -69,10 +74,7 @@ function middleware (api, { baseIriFromRequest, loader, store, middleware = {} }
throw new Error('no loader or store provided')
}

if (middleware.resource) {
router.use(waitFor(init, () => middleware.resource))
}
router.use(waitFor(init, () => operation(api)))
router.use(operation(middleware))

return router
}
Expand Down
11 changes: 11 additions & 0 deletions test-e2e/blog/category/orphaned-object-with-operation.hydra
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
PREFIX api: <http://localhost:9000/api/schema/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>

ENTRYPOINT "category/rdf"

With Class api:Category {
Expect Link api:post {
// <post/3> will return 405 because it has an operation supported by <pinned-post> property
Expect Status 405
}
}
10 changes: 10 additions & 0 deletions test-e2e/blog/category/orphaned-object.hydra
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
PREFIX api: <http://localhost:9000/api/schema/>

ENTRYPOINT "category/csvw"

With Class api:Category {
Expect Link api:post {
// <post/2> will return 404 because it is an triple object but none of the properties have any operations
Expect Status 404
}
}
1 change: 0 additions & 1 deletion test-e2e/blog/post/comment.hydra
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
PREFIX api: <http://localhost:9000/api/schema/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX dc: <http://purl.org/dc/elements/1.1/>
PREFIX schema: <http://schema.org/>

Expand Down
15 changes: 8 additions & 7 deletions test/middleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ describe('hydra-box', () => {
const middleware = sinon.spy((req, res, next) => next())
app.use(hydraBox(api, {
loader: {
forClassOperation: () => [loadedResource]
forClassOperation: () => [loadedResource],
forPropertyOperation: () => []
},
middleware: {
resource: middleware
Expand Down Expand Up @@ -66,7 +67,8 @@ describe('hydra-box', () => {
]
app.use(hydraBox(api, {
loader: {
forClassOperation: () => [loadedResource]
forClassOperation: () => [loadedResource],
forPropertyOperation: () => []
},
middleware: {
resource: middlewares
Expand All @@ -84,20 +86,19 @@ describe('hydra-box', () => {

it('calls resource middleware after loader', async () => {
// given
let receivedResource
const loadedResource = {
dataset: RDF.dataset(),
term: RDF.blankNode(),
types: []
}
const app = express()
const middleware = sinon.spy((req, res, next) => {
receivedResource = req.hydra.resource
const middleware = sinon.stub().callsFake((req, res, next) => {
next()
})
app.use(hydraBox(api, {
loader: {
forClassOperation: () => [loadedResource]
forClassOperation: () => [loadedResource],
forPropertyOperation: () => []
},
middleware: {
resource: middleware
Expand All @@ -108,7 +109,7 @@ describe('hydra-box', () => {
await request(app).get('/')

// then
assert.strictEqual(receivedResource, loadedResource)
assert(middleware.called)
})

describe('api', () => {
Expand Down
Loading

0 comments on commit 14cdcee

Please sign in to comment.