Skip to content

Commit

Permalink
feat: paging strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Jan 13, 2025
1 parent 2340e47 commit fd557ca
Show file tree
Hide file tree
Showing 16 changed files with 628 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/short-lemons-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopflos-cms/core": minor
---

Changed plugin setup to require classes
5 changes: 5 additions & 0 deletions .changeset/shy-bobcats-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopflos-cms/core": patch
---

Added helper to easily access plugin instance
5 changes: 5 additions & 0 deletions .changeset/tall-maps-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopflos-cms/hydra": minor
---

Created extensible method for collection paging strategies
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, KopflosPlugin, PluginConfig, ResultEnvelope } from './lib/Kopflos.js'
export type { KopflosResponse, KopflosPlugin, PluginConfig, ResultEnvelope, KopflosPluginConstructor } from './lib/Kopflos.js'
export type { Kopflos, KopflosConfig, Body, Query } from './lib/Kopflos.js'
export { default } from './lib/Kopflos.js'
export { loadHandlers as defaultHandlerLookup } from './lib/handler.js'
Expand Down
39 changes: 25 additions & 14 deletions packages/core/lib/Kopflos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,32 @@ export interface ResultEnvelope {

export type KopflosResponse = ResultBody | ResultEnvelope

export interface PluginConfig {
[plugin: string]: unknown
}

export interface KopflosPlugin {
readonly name: string
build?: () => Promise<void> | void
onStart?(): Promise<void> | void
onStop?(): Promise<void> | void
apiTriples?(): Promise<DatasetCore | Stream> | DatasetCore | Stream
}

export interface Kopflos<D extends DatasetCore = Dataset> {
get dataset(): D
get env(): KopflosEnvironment
get apis(): MultiPointer<Term, D>
// eslint-disable-next-line no-use-before-define
get plugins(): Array<KopflosPlugin>
get start(): () => Promise<void>
getPlugin<P extends KopflosPlugin, N extends keyof PluginConfig = keyof PluginConfig>(name: N): P | undefined
handleRequest(req: KopflosRequest<D>): Promise<ResultEnvelope>
loadApiGraphs(): Promise<void>
}

export interface KopflosPlugin {
build?: () => Promise<void> | void
onStart?(instance: Kopflos): Promise<void> | void
onStop?(instance: Kopflos): Promise<void> | void
apiTriples?(instance: Kopflos): Promise<DatasetCore | Stream> | DatasetCore | Stream
export interface KopflosPluginConstructor {
new(instance: Kopflos): KopflosPlugin
}

interface Clients {
Expand All @@ -81,10 +91,6 @@ interface Clients {

type Endpoint = string | EndpointOptions | Clients | Client

export interface PluginConfig {
[plugin: string]: unknown
}

export interface KopflosConfig {
[key: string]: unknown
mode?: 'development' | 'production'
Expand All @@ -101,7 +107,7 @@ export interface Options {
resourceShapeLookup?: ResourceShapeLookup
resourceLoaderLookup?: ResourceLoaderLookup
handlerLookup?: HandlerLookup
plugins?: Array<KopflosPlugin>
plugins?: Array<KopflosPluginConstructor>
}

export default class Impl implements Kopflos {
Expand All @@ -112,7 +118,7 @@ export default class Impl implements Kopflos {

constructor({ variables = {}, ...config }: KopflosConfig, private readonly options: Options = {}) {
this.env = createEnv({ variables, ...config })
this.plugins = options.plugins || []
this.plugins = (options.plugins || []).map(Plugin => new Plugin(this))

this.dataset = this.env.dataset([
...options.dataset || [],
Expand All @@ -134,7 +140,7 @@ export default class Impl implements Kopflos {
})

this.start = onetime(async function (this: Impl) {
await Promise.all(this.plugins.map(plugin => plugin.onStart?.(this)))
await Promise.all(this.plugins.map(plugin => plugin.onStart?.()))
}).bind(this)
}

Expand All @@ -146,6 +152,10 @@ export default class Impl implements Kopflos {
return this.graph.has(this.env.ns.rdf.type, this.env.ns.kopflos.Api)
}

getPlugin<P extends KopflosPlugin, N extends keyof PluginConfig>(name: N) {
return this.plugins.find(plugin => plugin.name === name) as unknown as P | undefined
}

async getResponse(req: KopflosRequest<Dataset>): Promise<KopflosResponse | undefined | null> {
const resourceShapeMatch = await this.findResourceShape(req.iri)
if (isResponse(resourceShapeMatch)) {
Expand All @@ -169,6 +179,7 @@ export default class Impl implements Kopflos {
: {}
const args: HandlerArgs = {
...req,
instance: this,
headers: req.headers,
resourceShape,
env: this.env,
Expand Down Expand Up @@ -348,7 +359,7 @@ export default class Impl implements Kopflos {
return
}

const triples = await plugin.apiTriples(this)
const triples = await plugin.apiTriples()
for await (const quad of triples) {
this.dataset.add(quad)
}
Expand All @@ -366,6 +377,6 @@ export default class Impl implements Kopflos {
}

async stop() {
await Promise.all(this.plugins.map(async plugin => { plugin.onStop?.(this) }))
await Promise.all(this.plugins.map(async plugin => { plugin.onStop?.() }))
}
}
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> {
instance: Kopflos
resourceShape: GraphPointer<NamedNode, D>
env: KopflosEnvironment
subject: GraphPointer<NamedNode, D>
Expand Down
77 changes: 73 additions & 4 deletions packages/hydra/handlers/collection.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,100 @@
import type { Readable } from 'node:stream'
import merge from '@sindresorhus/merge-streams'
import type { Handler } from '@kopflos-cms/core'
import { log } from '@kopflos-cms/core'
import { constructQuery } from '@hydrofoil/shape-to-query'
import constraints from '@hydrofoil/shape-to-query/constraints.js'
// eslint-disable-next-line import/no-unresolved
import { kl } from '@kopflos-cms/core/ns.js'
import error from 'http-errors'
import { isGraphPointer } from 'is-graph-pointer'
import type { GraphPointer } from 'clownface'
import type { IriTemplate } from '@rdfine/hydra'
import { memberQueryShape, totalsQueryShape } from '../lib/queryShapes.js'
import { HydraMemberAssertionConstraint } from '../lib/shaclConstraint/HydraMemberAssertionConstraint.js'
import { isReadable, isWritable } from '../lib/collection.js'
import { combineTemplate, fromQuery } from '../lib/iriTemplate.js'
import { tryParse } from '../lib/number.js'
import type { HydraPlugin } from '../index.js'
import type { PrepareExpansionModel } from '../lib/partialCollection/index.js'

constraints.set(kl['hydra#MemberAssertionConstraintComponent'], HydraMemberAssertionConstraint)

export function get(): Handler {
return ({ env, subject }) => {
return async ({ instance, subject, ...req }) => {
const hydraPlugin = instance.getPlugin<HydraPlugin>('@kopflos-cms/hydra')
if (!hydraPlugin) {
throw new Error('Hydra plugin not loaded')
}

const { env } = hydraPlugin
const { hydra, rdf } = env.ns

if (!isReadable(env, subject)) {
return new error.MethodNotAllowed('Collection is not readable')
}

const memberQuery = constructQuery(memberQueryShape({ env, collection: subject }))
const strategy = hydraPlugin.partialCollectionStrategies.find(strategy => strategy.isApplicableTo(subject))

if (!strategy) {
log.warn('No strategy found for collection', subject.value)
}

let limit: number | undefined
let offset: number | undefined
let query: GraphPointer | undefined
const template = subject.out(hydra.search)
if (strategy && isGraphPointer(template)) {
query = fromQuery(env, req.query, template)
;({ limit, offset } = strategy.getLimitOffset({ collection: subject, query }))
}

const memberQuery = constructQuery(memberQueryShape({ env, collection: subject, limit, offset }))
const members = env.sparql.default.stream.query.construct(memberQuery)

const totalQuery = constructQuery(totalsQueryShape({ env, collection: subject }))
const totals = env.sparql.default.stream.query.construct(totalQuery)
const totals = await env.dataset().import(env.sparql.default.stream.query.construct(totalQuery))

const view = env.clownface()
if (strategy && query && isGraphPointer(template)) {
const templateObj = env.rdfine.hydra.IriTemplate(template) as unknown as IriTemplate
const totalItems = tryParse(env.clownface({ dataset: totals })
.has(hydra.totalItems)
.out(hydra.totalItems))

const cloneQuery = () => {
return env.clownface({
dataset: env.dataset([...query.dataset]),
term: query.term,
})
}

function createPageLink(prepareExpansionModel: PrepareExpansionModel) {
return view.namedNode(
combineTemplate(subject, templateObj.expand(prepareExpansionModel({
query: cloneQuery(), totalItems, collection: subject,
}))),
)
}

const { first, last, next, previous } = strategy.viewLinksTemplateParams
view.node(subject).addOut(hydra.view, view => {
view
.addOut(rdf.type, hydra.PartialCollectionView)
.addOut(hydra.first, createPageLink(first))
.addOut(hydra.last, createPageLink(last))
.addOut(hydra.next, createPageLink(next))
.addOut(hydra.previous, createPageLink(previous))
})
}

return {
status: 200,
body: merge([members, totals]),
body: merge([
members,
totals.toStream() as unknown as Readable,
view.dataset.toStream() as unknown as Readable,
]),
}
}
}
Expand Down
54 changes: 42 additions & 12 deletions packages/hydra/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { Kopflos, KopflosPlugin } from '@kopflos-cms/core'
import type { Kopflos, KopflosEnvironment, KopflosPlugin, KopflosPluginConstructor } from '@kopflos-cms/core'
import type { NamedNode } from '@rdfjs/types'
import type { DerivedEnvironment } from '@zazuko/env'
import E from '@zazuko/env/Environment.js'
import { RdfineFactory } from '@tpluscode/rdfine'
import { HydraFactory } from '@rdfine/hydra/Factory'
import type { Environment } from '@rdfjs/environment/Environment.js'
import { createDefaultShapes, createHandlers } from './lib/resourceShapes.js'
import type { PartialCollectionStrategy } from './lib/partialCollection/index.js'
import limitOffsetStrategy from './lib/partialCollection/limitOffsetStrategy.js'
import pageIndexStrategy from './lib/partialCollection/pageIndexStrategy.js'

type ExtendingTerms = 'hydra#memberShape'
| 'hydra#MemberAssertionConstraintComponent'
Expand All @@ -17,6 +25,7 @@ export interface Options {
* The IRI of the API that the Hydra API Documentation will be generated and served for
*/
apis: Array<NamedNode | string>
partialCollectionStrategies?: PartialCollectionStrategy[]
}

declare module '@kopflos-cms/core' {
Expand All @@ -25,20 +34,41 @@ declare module '@kopflos-cms/core' {
}
}

export default (options : Options): KopflosPlugin => {
return {
async onStart(instance: Kopflos) {
const { env } = instance
const { kopflos: kl } = env.ns
export interface HydraPlugin extends KopflosPlugin {
readonly env: DerivedEnvironment<Environment<RdfineFactory | HydraFactory>, KopflosEnvironment>
readonly partialCollectionStrategies: PartialCollectionStrategy[]
}

export default (options : Options): KopflosPluginConstructor => {
return class implements HydraPlugin {
readonly env: DerivedEnvironment<Environment<RdfineFactory | HydraFactory>, KopflosEnvironment>
readonly partialCollectionStrategies: PartialCollectionStrategy[]

get name() {
return '@kopflos-cms/hydra'
}

const dataset = createDefaultShapes(env, options)
constructor(private readonly instance: Kopflos) {
this.env = new E([RdfineFactory, HydraFactory], { parent: instance.env })
this.partialCollectionStrategies = [
...options.partialCollectionStrategies ?? [],
limitOffsetStrategy,
pageIndexStrategy,
]
}

await env.sparql.default.stream.store.put(dataset.toStream(), {
async onStart() {
const { kopflos: kl } = this.env.ns

const dataset = createDefaultShapes(this.env, options)

await this.env.sparql.default.stream.store.put(dataset.toStream(), {
graph: kl.hydra,
})
},
async apiTriples(instance) {
return createHandlers(instance)
},
}

async apiTriples() {
return createHandlers(this.instance)
}
}
}
70 changes: 70 additions & 0 deletions packages/hydra/lib/iriTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ParsedUrlQuery } from 'node:querystring'
import { hydra } from '@tpluscode/rdf-ns-builders'
import type { GraphPointer } from 'clownface'
import type { Environment } from '@rdfjs/environment/Environment.js'
import type { DataFactory, NamedNode } from '@rdfjs/types'
import type ClownfaceFactory from 'clownface/Factory.js'

const literalValueRegex = /^"(?<value>.+)"(@|\^\^)?((?<=@)(?<language>.*))?((?<=\^\^)(?<datatype>.*))?$/

function createTermFromVariable(rdf: Environment<DataFactory>, template: GraphPointer, value: string | string[]) {
if (!hydra.ExplicitRepresentation.equals(template.out(hydra.variableRepresentation).term)) {
return value
}

const parseValue = (value: string) => {
const matches = value.match(literalValueRegex)
if (matches?.groups) {
let datatypeOrLanguage: NamedNode | string | undefined = matches.groups?.language
if (matches.groups?.datatype) {
datatypeOrLanguage = rdf.namedNode(matches.groups.datatype)
}

return rdf.literal(matches.groups.value, datatypeOrLanguage)
}

return rdf.namedNode(value)
}

const values = Array.isArray(value) ? value : [value]
return values.map(parseValue)
}

export function fromQuery(rdf: Environment<DataFactory | ClownfaceFactory>, query: ParsedUrlQuery, template: GraphPointer) {
const templateParams = rdf.clownface().blankNode()
const variablePropertyMap = new Map()

template.out(hydra.mapping).forEach(mapping => {
const variable = mapping.out(hydra.variable).value
const property = mapping.out(hydra.property).term

variablePropertyMap.set(variable, property)
})

Object.entries(query).forEach(([key, value]) => {
const property = variablePropertyMap.get(key)

if (!property || !value) {
return
}

templateParams.addOut(property, createTermFromVariable(rdf, template, value))
})

return templateParams
}

export function combineTemplate(collection: GraphPointer, expanded: string) {
let collectionURL = new URL(collection.value)

if (!expanded.startsWith('?') || expanded.startsWith('$')) {
const searchParams = new URLSearchParams(expanded)
for (const [param, value] of searchParams) {
collectionURL.searchParams.append(param, value)
}
} else {
collectionURL = new URL(expanded, collectionURL)
}

return collectionURL.toString()
}
Loading

0 comments on commit fd557ca

Please sign in to comment.