Skip to content

Commit

Permalink
feat: request decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Jan 30, 2025
1 parent df030e8 commit 608724b
Show file tree
Hide file tree
Showing 11 changed files with 321 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/violet-hats-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopflos-cms/core": patch
---

Added request decorators
1 change: 1 addition & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export type { ResourceLoader } from './lib/resourceLoader.js'
export type { Handler, SubjectHandler, ObjectHandler, HandlerArgs } from './lib/handler.js'
export { default as log, logCode } from './lib/log.js'
export type { KopflosEnvironment } from './lib/env/index.js'
export type { RequestDecorator } from './lib/decorators.js'
56 changes: 34 additions & 22 deletions packages/core/lib/Kopflos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type { Handler, HandlerArgs, HandlerLookup } from './handler.js'
import { loadHandlers } from './handler.js'
import type { HttpMethod } from './httpMethods.js'
import log from './log.js'
import type { DecoratorLookup } from './decorators.js'
import { loadDecorators } from './decorators.js'

declare module '@rdfjs/types' {
interface Stream extends AsyncIterable<Quad> {}
Expand Down Expand Up @@ -100,6 +102,7 @@ export interface Options {
dataset?: DatasetCore
resourceShapeLookup?: ResourceShapeLookup
resourceLoaderLookup?: ResourceLoaderLookup
decoratorLookup?: DecoratorLookup
handlerLookup?: HandlerLookup
plugins?: Array<KopflosPlugin>
}
Expand Down Expand Up @@ -146,7 +149,7 @@ export default class Impl implements Kopflos {
return this.graph.has(this.env.ns.rdf.type, this.env.ns.kopflos.Api)
}

async getResponse(req: KopflosRequest<Dataset>): Promise<KopflosResponse | undefined | null> {
async getResponse(req: KopflosRequest<Dataset>): Promise<KopflosResponse> {
const resourceShapeMatch = await this.findResourceShape(req.iri)
if (isResponse(resourceShapeMatch)) {
return resourceShapeMatch
Expand Down Expand Up @@ -182,28 +185,36 @@ export default class Impl implements Kopflos {
args.object = resourceGraph.node(resourceShapeMatch.object)
}

let response: ResultEnvelope | undefined
for (let i = 0; i < handlerChain.length; i++) {
const handler = handlerChain[i]
const rawResult = await handler(args, response)
if (!rawResult) {
return rawResult
}
type HandlerClosure = () => Promise<KopflosResponse> | KopflosResponse
const runHandlers: HandlerClosure = async () => {
let response: ResultEnvelope | undefined
for (let i = 0; i < handlerChain.length; i++) {
const handler = handlerChain[i]
const rawResult = await handler(args, response)

response = this.asEnvelope(rawResult)
if (response.end) {
break
response = this.asEnvelope(rawResult)
if (response.end) {
break
}
}

return this.asEnvelope(response)
}

return response
const decoratorLookup = this.options.decoratorLookup || loadDecorators
const decorators = await decoratorLookup(this, args)

const decoratedHandlers = decorators.reduceRight<HandlerClosure>((next, decorator) =>
decorator.bind(null, args, async () => this.asEnvelope(await next())), runHandlers)

return decoratedHandlers()
}

async handleRequest(req: KopflosRequest<Dataset>): Promise<ResultEnvelope> {
log.info(`${req.method} ${req.iri.value}`)
log.debug('Request headers', req.headers)

let result: KopflosResponse | undefined | null
let result: KopflosResponse
try {
result = await this.getResponse(req)
} catch (cause: Error | unknown) {
Expand All @@ -219,14 +230,6 @@ export default class Impl implements Kopflos {
}
}

if (!result) {
log.error('Falsy result returned from handler')
return {
status: 500,
body: new Error('Handler did not return a result'),
}
}

if (!this.isEnvelope(result)) {
result = {
status: 200,
Expand Down Expand Up @@ -308,7 +311,16 @@ export default class Impl implements Kopflos {
return 'body' in arg || 'status' in arg
}

private asEnvelope(arg: KopflosResponse): ResultEnvelope {
private asEnvelope(arg: KopflosResponse | undefined): ResultEnvelope {
if (!arg) {
log.error('Falsy result returned from handler')
return {
status: 500,
body: new Error('Handler did not return a result'),
end: true,
}
}

if (this.isEnvelope(arg)) {
return arg
}
Expand Down
33 changes: 33 additions & 0 deletions packages/core/lib/decorators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { DatasetCore } from '@rdfjs/types'
import { kl } from '../ns.js'
import type { Kopflos, KopflosResponse, ResultEnvelope } from './Kopflos.js'
import type { HandlerArgs } from './handler.js'
import log from './log.js'

export interface RequestDecorator {
applicable?: (args: HandlerArgs) => boolean
(args: HandlerArgs, next: () => Promise<ResultEnvelope>): Promise<KopflosResponse> | KopflosResponse
}

export interface DecoratorLookup {
(kopflos: Kopflos, args: HandlerArgs): Promise<RequestDecorator[]>
}

export const loadDecorators = async ({ env, apis }: Pick<Kopflos<DatasetCore>, 'env' | 'apis'>, args: HandlerArgs) => {
const api = apis.node(args.resourceShape.out(kl.api))

const decorators = api.out(kl.decorator)

const loaded = await Promise.all(decorators.map(async decorator => {
const implNode = decorator.out(env.ns.code.implementedBy)
const impl = await env.load<RequestDecorator>(implNode)
if (!impl) {
log.warn('Decorator has no implementation')
}
if (!impl?.applicable || impl.applicable(args)) {
return impl
}
}))

return loaded.filter(Boolean) as RequestDecorator[]
}
1 change: 0 additions & 1 deletion packages/core/lib/env/CodeLoadersFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export class CodeLoadersFactory {
}
this.load = (<T>(code: AnyPointer): Promise<T> | T | undefined => {
if (!isGraphPointer(code)) {
log.warn('Code loader called with non-pointer. Expected a NamedNode or BlankNode')
return undefined
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/lib/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { logCode } from './log.js'
type Dataset = ReturnType<KopflosEnvironment['dataset']>

export interface HandlerArgs<D extends DatasetCore = Dataset> {
method: string
resourceShape: GraphPointer<NamedNode, D>
env: KopflosEnvironment
subject: GraphPointer<NamedNode, D>
Expand Down
2 changes: 1 addition & 1 deletion packages/core/ns.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import rdf from '@zazuko/env-node'

type Properties = 'api' | 'resourceLoader' | 'handler' | 'method' | 'config'
type Properties = 'api' | 'resourceLoader' | 'handler' | 'method' | 'config' | 'decorator'
type Classes = 'Config' | 'Api' | 'ResourceShape' | 'Handler'
type Shorthands = 'DescribeLoader' | 'OwnGraphLoader'

Expand Down
72 changes: 72 additions & 0 deletions packages/core/test/lib/Kopflos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as resourceLoaders from '../../resourceLoaders.js'
import inMemoryClients from '../../../testing-helpers/in-memory-clients.js'
import { loadPlugins } from '../../plugins.js'
import { kl } from '../../ns.js'
import type { RequestDecorator } from '../../lib/decorators.js'

describe('lib/Kopflos', () => {
use(snapshots)
Expand Down Expand Up @@ -225,6 +226,77 @@ describe('lib/Kopflos', () => {
})
})

describe('decorators', () => {
it('decorator can end the request immediately', async function () {
// given
const handler = sinon.spy()
const kopflos = new Kopflos(config, {
dataset: this.rdf.dataset,
decoratorLookup: async () => [() => ({ status: 200, body: 'decorated' })],
resourceShapeLookup: async () => [{
api: ex.api,
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: () => [handler],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

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

// then
expect(response.status).to.eq(200)
expect(response.body).to.eq('decorated')
expect(handler).not.to.have.been.called
})

it('decorator can modify handler result', async function () {
// given
const handler = () => ({
status: 200,
body: 'response',
})
const decorator: RequestDecorator = async (args, next) => {
const response = await next()
return {
...response,
body: response.body + ' decorated',
}
}
const kopflos = new Kopflos(config, {
dataset: this.rdf.dataset,
decoratorLookup: async () => [decorator],
resourceShapeLookup: async () => [{
api: ex.api,
resourceShape: ex.FooShape,
subject: ex.foo,
}],
handlerLookup: () => [handler],
resourceLoaderLookup: async () => () => rdf.dataset().toStream(),
})

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

// then
expect(response.status).to.eq(200)
expect(response.body).to.eq('response decorated')
})
})

it('guards against falsy handler result', async function () {
// given
const kopflos = new Kopflos(config, {
Expand Down
85 changes: 85 additions & 0 deletions packages/core/test/lib/decorators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import url from 'node:url'
import { createStore } from 'mocha-chai-rdf/store.js'
import { expect } from 'chai'
import type { DatasetCore } from '@rdfjs/types'
import rdf from '@zazuko/env-node'
import { loadDecorators } from '../../lib/decorators.js'
import type { HandlerArgs } from '../../lib/handler.js'
import { createEnv } from '../../lib/env/index.js'
import type { Kopflos, KopflosConfig } from '../../lib/Kopflos.js'
import { ex } from '../../../testing-helpers/ns.js'
import { kl } from '../../ns.js'
import { bar, foo, personOnly } from '../support/decorators.js'

describe('@kopflos-cms/core/lib/decorators.js', () => {
let kopflos: Pick<Kopflos<DatasetCore>, 'env' | 'apis'>
const config: KopflosConfig = {
baseIri: 'http://localhost:1429/',
sparql: {
default: {
endpointUrl: 'http://localhost:7878/query?union-default-graph',
updateUrl: 'http://localhost:7878/update',
},
},
codeBase: url.fileURLToPath(import.meta.url),
}

beforeEach(createStore(import.meta.url, {
format: 'trig',
}))

beforeEach(function () {
const apis = this.rdf.graph.has(rdf.ns.rdf.type, kl.Api)
kopflos = {
env: createEnv(config),
apis,
}
})

describe('loadDecorators', () => {
context('decorators without implementation', () => {
it('are skipped', async function () {
// given
const args = <HandlerArgs>{
resourceShape: this.rdf.graph.namedNode(ex.resourceShape),
}

// when
const decorators = await loadDecorators(kopflos, args)

// then
expect(decorators).to.be.empty
})
})

context('decorators with implementations', () => {
it('are loaded', async function () {
// given
const args = <HandlerArgs>{
resourceShape: this.rdf.graph.namedNode(ex.resourceShape),
}

// when
const decorators = await loadDecorators(kopflos, args)

// then
expect(decorators).to.contain.all.members([foo, bar])
})
})

context('decorators with limitations', () => {
it('loads only those passing the check', async function () {
// given
const args = <HandlerArgs>{
resourceShape: this.rdf.graph.namedNode(ex.resourceShape),
}

// when
const decorators = await loadDecorators(kopflos, args)

// then
expect(decorators).to.deep.eq([personOnly])
})
})
})
})
Loading

0 comments on commit 608724b

Please sign in to comment.