Skip to content

Commit

Permalink
feat: handler chains
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Oct 4, 2024
1 parent 25d3dce commit a72254b
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/slimy-frogs-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopflos-cms/core": patch
---

Object of `kl:handler` can now be an RDF List of handler implementations which will be called in sequence
2 changes: 1 addition & 1 deletion packages/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type { KopflosResponse } from './lib/Kopflos.js'
export type { KopflosResponse, ResultEnvelope } from './lib/Kopflos.js'
export type { Kopflos, KopflosConfig, Body, Query } from './lib/Kopflos.js'
export { default } from './lib/Kopflos.js'
export { loadHandler as defaultHandlerLookup } from './lib/handler.js'
Expand Down
35 changes: 27 additions & 8 deletions packages/core/lib/Kopflos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface ResultEnvelope {
body?: ResultBody | string
status?: number
headers?: OutgoingHttpHeaders
end?: boolean
}
export type KopflosResponse = ResultBody | ResultEnvelope

Expand Down Expand Up @@ -118,9 +119,9 @@ export default class Impl implements Kopflos {
return loader
}
const coreRepresentation = loader(resourceShapeMatch.subject, this)
const handler = await this.loadHandler(req.method, resourceShapeMatch, coreRepresentation)
if (isResponse(handler)) {
return handler
const handlerChain = await this.loadHandlerChain(req.method, resourceShapeMatch, coreRepresentation)
if (isResponse(handlerChain)) {
return handlerChain
}
const resourceGraph = this.env.clownface({
dataset: await this.env.dataset().import(coreRepresentation),
Expand All @@ -138,7 +139,15 @@ export default class Impl implements Kopflos {
args.property = resourceShapeMatch.property
args.object = resourceGraph.node(resourceShapeMatch.object)
}
return handler(args)
const [handler, ...rest] = handlerChain
let response = this.asEnvelope(await handler(args, undefined))
for (const handler of rest) {
response = this.asEnvelope(await handler(args, response))
if (response.end) {
break
}
}
return response
}

async handleRequest(req: KopflosRequest<Dataset>): Promise<ResultEnvelope> {
Expand Down Expand Up @@ -225,13 +234,13 @@ export default class Impl implements Kopflos {
return fromOwnGraph
}

async loadHandler(method: HttpMethod, resourceShapeMatch: ResourceShapeMatch, coreRepresentation: Stream): Promise<Handler | KopflosResponse> {
async loadHandlerChain(method: HttpMethod, resourceShapeMatch: ResourceShapeMatch, coreRepresentation: Stream): Promise<Handler[] | KopflosResponse> {
const handlerLookup = this.options.handlerLookup || loadHandler

const handler = await handlerLookup(resourceShapeMatch, method, this)
const handlers = await Promise.all(handlerLookup(resourceShapeMatch, method, this))

if (handler) {
return handler
if (handlers.length) {
return handlers
}

if (!('property' in resourceShapeMatch) && (method === 'GET' || method === 'HEAD')) {
Expand All @@ -250,6 +259,16 @@ export default class Impl implements Kopflos {
return 'body' in arg || 'status' in arg
}

private asEnvelope(arg: KopflosResponse): ResultEnvelope {
if (this.isEnvelope(arg)) {
return arg
}
return {
status: 200,
body: arg,
}
}

static async fromGraphs(kopflos: Impl, ...graphs: Array<NamedNode | string>): Promise<void> {
const graphsIris = graphs.map(graph => typeof graph === 'string' ? kopflos.env.namedNode(graph) : graph)
log.info('Loading graphs', graphsIris.map(g => g.value))
Expand Down
25 changes: 19 additions & 6 deletions packages/core/lib/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { IncomingHttpHeaders } from 'node:http'
import type { AnyPointer, GraphPointer } from 'clownface'
import type { DatasetCore, NamedNode } from '@rdfjs/types'
import type { KopflosEnvironment } from './env/index.js'
import type { Kopflos, KopflosResponse, Body, Query } from './Kopflos.js'
import type { Kopflos, KopflosResponse, Body, Query, ResultEnvelope } from './Kopflos.js'
import type { ResourceShapeMatch } from './resourceShape.js'
import type { HttpMethod } from './httpMethods.js'
import { logCode } from './log.js'
Expand All @@ -21,20 +21,20 @@ export interface HandlerArgs<D extends DatasetCore = Dataset> {
}

export interface SubjectHandler {
(arg: HandlerArgs): KopflosResponse | Promise<KopflosResponse>
(arg: HandlerArgs, response?: ResultEnvelope): KopflosResponse | Promise<KopflosResponse>
}

export interface ObjectHandler {
(arg: Required<HandlerArgs>): KopflosResponse | Promise<KopflosResponse>
(arg: Required<HandlerArgs>, response?: ResultEnvelope): KopflosResponse | Promise<KopflosResponse>
}

export type Handler = SubjectHandler | ObjectHandler

export interface HandlerLookup {
(match: ResourceShapeMatch, method: HttpMethod, kopflos: Kopflos): Promise<Handler | undefined> | Handler | undefined
(match: ResourceShapeMatch, method: HttpMethod, kopflos: Kopflos): Array<Promise<Handler> | Handler>
}

export function loadHandler({ resourceShape, ...rest }: ResourceShapeMatch, method: HttpMethod, { apis, env }: Kopflos) {
export const loadHandler: HandlerLookup = ({ resourceShape, ...rest }: ResourceShapeMatch, method: HttpMethod, { apis, env }: Kopflos) => {
const api = apis.node(rest.api)

let shape: AnyPointer = api.node(resourceShape)
Expand All @@ -50,8 +50,21 @@ export function loadHandler({ resourceShape, ...rest }: ResourceShapeMatch, meth
.filter(matchingMethod(env, method))

const impl = handler.out(env.ns.code.implementedBy)
if (impl.isList()) {
const pointers = [...impl.list()]
return pointers.map(chainedHandler => {
logCode(chainedHandler, 'handler')
return env.load<Handler>(chainedHandler)
}).filter(Boolean) as Array<Promise<Handler>>
}

logCode(impl, 'handler')
return env.load<Handler>(impl)
const loaded = env.load<Handler>(impl)
if (loaded) {
return [loaded]
}

return []
}

function matchingMethod(env: KopflosEnvironment, requestMethod: HttpMethod): Parameters<AnyPointer['filter']>[0] {
Expand Down
137 changes: 119 additions & 18 deletions packages/core/test/lib/Kopflos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => testHandler,
handlerLookup: () => [testHandler],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand All @@ -82,6 +82,107 @@ describe('lib/Kopflos', () => {
expect(response).toMatchSnapshot()
})

context('handler chains', () => {
it('can access previous handler', async function () {
// given
const chainedHandler = (letter: string): Handler => (arg, previous) => {
return {
status: 200,
body: (previous?.body || '') + letter,
}
}
const kopflos = new Kopflos(config, {
dataset: this.rdf.dataset,
resourceShapeLookup: async () => [{
api: ex.api,
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: () => [chainedHandler('A'), chainedHandler('B'), chainedHandler('C')],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

// when
const response = await kopflos.handleRequest({
iri: ex.foo,
method: 'GET',
headers: {},
body: {} as Body,
query: {},
})

// then
expect(response.body).to.eq('ABC')
})

it('can short-circuit a chain', async function () {
// given
const chainedHandler = (letter: string): Handler => (arg, previous) => {
return {
status: 200,
body: (previous?.body || '') + letter,
end: letter === arg.query.last,
}
}
const kopflos = new Kopflos(config, {
dataset: this.rdf.dataset,
resourceShapeLookup: async () => [{
api: ex.api,
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: () => [chainedHandler('A'), chainedHandler('B'), chainedHandler('C')],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

// when
const response = await kopflos.handleRequest({
iri: ex.foo,
method: 'GET',
headers: {},
body: {} as Body,
query: {
last: 'B',
},
})

// then
expect(response.body).to.eq('AB')
})

it('can replace previous response', async function () {
// given
const chainedHandler = (letter: string): Handler => () => {
return {
status: 200,
body: letter,
}
}
const kopflos = new Kopflos(config, {
dataset: this.rdf.dataset,
resourceShapeLookup: async () => [{
api: ex.api,
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: () => [chainedHandler('A'), chainedHandler('B'), chainedHandler('C')],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

// when
const response = await kopflos.handleRequest({
iri: ex.foo,
method: 'GET',
headers: {},
body: {} as Body,
query: {},
})

// then
expect(response.body).to.eq('C')
})
})

it('guards against falsy handler result', async function () {
// given
const kopflos = new Kopflos(config, {
Expand All @@ -91,7 +192,7 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => () => undefined as unknown as KopflosResponse,
handlerLookup: () => [() => undefined as unknown as KopflosResponse],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand All @@ -118,7 +219,7 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => () => body,
handlerLookup: () => [() => body],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand Down Expand Up @@ -148,12 +249,12 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => ({ headers }) => {
handlerLookup: () => [({ headers }) => {
return {
status: 200,
body: JSON.stringify({ headers }),
}
},
}],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand Down Expand Up @@ -181,12 +282,12 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => ({ headers }) => {
handlerLookup: () => [({ headers }) => {
return {
status: 200,
body: JSON.stringify({ headers: Object.keys(headers).length }),
}
},
}],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand All @@ -211,22 +312,22 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => testHandler,
handlerLookup: () => [testHandler],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
}

const throws = async () => {
const throws = () => {
throw new Error('Error')
}
const throwsNonError = async () => {
const throwsNonError = () => {
// eslint-disable-next-line no-throw-literal
throw 'Error'
}
const failingFunctions: [string, Partial<Options>][] = [throws, throwsNonError].flatMap(fun => [
['resourceShapeLookup ' + fun.name, { resourceShapeLookup: fun }],
['resourceLoaderLookup ' + fun.name, { resourceLoaderLookup: fun }],
['handlerLookup ' + fun.name, { handlerLookup: fun }],
['handler ' + fun.name, { handlerLookup: () => fun }],
['handler ' + fun.name, { handlerLookup: () => [fun] }],
])

for (const [name, failingFunction] of failingFunctions) {
Expand Down Expand Up @@ -263,12 +364,12 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => ({ body }) => {
handlerLookup: () => [({ body }) => {
return {
status: 200,
body: JSON.stringify({ body: !!body }),
}
},
}],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand Down Expand Up @@ -298,7 +399,7 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => undefined,
handlerLookup: () => [],
})

// when
Expand Down Expand Up @@ -329,7 +430,7 @@ describe('lib/Kopflos', () => {
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: async () => undefined,
handlerLookup: () => [],
})

// when
Expand Down Expand Up @@ -360,7 +461,7 @@ describe('lib/Kopflos', () => {
property: ex.bar,
object: ex.baz,
}],
handlerLookup: async () => testHandler,
handlerLookup: () => [testHandler],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand Down Expand Up @@ -389,7 +490,7 @@ describe('lib/Kopflos', () => {
property: ex.bar,
object: ex.baz,
}],
handlerLookup: async () => testHandler,
handlerLookup: () => [testHandler],
resourceLoaderLookup: async () => resourceLoader,
})

Expand Down Expand Up @@ -420,7 +521,7 @@ describe('lib/Kopflos', () => {
property: ex.bar,
object: ex.baz,
}],
handlerLookup: async () => undefined,
handlerLookup: () => [],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

Expand Down

0 comments on commit a72254b

Please sign in to comment.