From b84bd82471851666eb5648e6536976120043ec47 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Mon, 13 Jan 2025 18:03:46 +0100 Subject: [PATCH 01/18] feat: paging strategy --- .changeset/short-lemons-agree.md | 5 + .changeset/shy-bobcats-reply.md | 5 + .changeset/tall-maps-thank.md | 5 + packages/core/index.ts | 2 +- packages/core/lib/Kopflos.ts | 39 ++-- packages/core/lib/handler.ts | 1 + packages/hydra/handlers/collection.ts | 77 +++++++- packages/hydra/index.ts | 54 ++++-- packages/hydra/lib/iriTemplate.ts | 70 +++++++ packages/hydra/lib/number.ts | 18 ++ packages/hydra/lib/partialCollection/index.ts | 32 ++++ .../partialCollection/limitOffsetStrategy.ts | 66 +++++++ .../partialCollection/pageIndexStrategy.ts | 54 ++++++ packages/hydra/package.json | 3 + packages/hydra/test/collection.test.ts | 175 +++++++++++++++++- packages/hydra/test/collection.test.ts.trig | 54 ++++++ 16 files changed, 628 insertions(+), 32 deletions(-) create mode 100644 .changeset/short-lemons-agree.md create mode 100644 .changeset/shy-bobcats-reply.md create mode 100644 .changeset/tall-maps-thank.md create mode 100644 packages/hydra/lib/iriTemplate.ts create mode 100644 packages/hydra/lib/number.ts create mode 100644 packages/hydra/lib/partialCollection/index.ts create mode 100644 packages/hydra/lib/partialCollection/limitOffsetStrategy.ts create mode 100644 packages/hydra/lib/partialCollection/pageIndexStrategy.ts diff --git a/.changeset/short-lemons-agree.md b/.changeset/short-lemons-agree.md new file mode 100644 index 0000000..35a6d8e --- /dev/null +++ b/.changeset/short-lemons-agree.md @@ -0,0 +1,5 @@ +--- +"@kopflos-cms/core": minor +--- + +Changed plugin setup to require classes diff --git a/.changeset/shy-bobcats-reply.md b/.changeset/shy-bobcats-reply.md new file mode 100644 index 0000000..352aa7c --- /dev/null +++ b/.changeset/shy-bobcats-reply.md @@ -0,0 +1,5 @@ +--- +"@kopflos-cms/core": patch +--- + +Added helper to easily access plugin instance diff --git a/.changeset/tall-maps-thank.md b/.changeset/tall-maps-thank.md new file mode 100644 index 0000000..8f1099f --- /dev/null +++ b/.changeset/tall-maps-thank.md @@ -0,0 +1,5 @@ +--- +"@kopflos-cms/hydra": minor +--- + +Created extensible method for collection paging strategies diff --git a/packages/core/index.ts b/packages/core/index.ts index 2a47e27..c5effdf 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -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' diff --git a/packages/core/lib/Kopflos.ts b/packages/core/lib/Kopflos.ts index 18e9498..b5e10f1 100644 --- a/packages/core/lib/Kopflos.ts +++ b/packages/core/lib/Kopflos.ts @@ -56,6 +56,18 @@ export interface ResultEnvelope { export type KopflosResponse = ResultBody | ResultEnvelope +export interface PluginConfig { + [plugin: string]: unknown +} + +export interface KopflosPlugin { + readonly name: string + build?: () => Promise | void + onStart?(): Promise | void + onStop?(): Promise | void + apiTriples?(): Promise | DatasetCore | Stream +} + export interface Kopflos { get dataset(): D get env(): KopflosEnvironment @@ -63,15 +75,13 @@ export interface Kopflos { // eslint-disable-next-line no-use-before-define get plugins(): Array get start(): () => Promise + getPlugin

(name: N): P | undefined handleRequest(req: KopflosRequest): Promise loadApiGraphs(): Promise } -export interface KopflosPlugin { - build?: () => Promise | void - onStart?(instance: Kopflos): Promise | void - onStop?(instance: Kopflos): Promise | void - apiTriples?(instance: Kopflos): Promise | DatasetCore | Stream +export interface KopflosPluginConstructor { + new(instance: Kopflos): KopflosPlugin } interface Clients { @@ -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' @@ -101,7 +107,7 @@ export interface Options { resourceShapeLookup?: ResourceShapeLookup resourceLoaderLookup?: ResourceLoaderLookup handlerLookup?: HandlerLookup - plugins?: Array + plugins?: Array } export default class Impl implements Kopflos { @@ -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 || [], @@ -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) } @@ -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

(name: N) { + return this.plugins.find(plugin => plugin.name === name) as unknown as P | undefined + } + async getResponse(req: KopflosRequest): Promise { const resourceShapeMatch = await this.findResourceShape(req.iri) if (isResponse(resourceShapeMatch)) { @@ -169,6 +179,7 @@ export default class Impl implements Kopflos { : {} const args: HandlerArgs = { ...req, + instance: this, headers: req.headers, resourceShape, env: this.env, @@ -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) } @@ -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?.() })) } } diff --git a/packages/core/lib/handler.ts b/packages/core/lib/handler.ts index 5b0e5db..df360ea 100644 --- a/packages/core/lib/handler.ts +++ b/packages/core/lib/handler.ts @@ -12,6 +12,7 @@ import { logCode } from './log.js' type Dataset = ReturnType export interface HandlerArgs { + instance: Kopflos resourceShape: GraphPointer env: KopflosEnvironment subject: GraphPointer diff --git a/packages/hydra/handlers/collection.ts b/packages/hydra/handlers/collection.ts index 5be294b..074cba8 100644 --- a/packages/hydra/handlers/collection.ts +++ b/packages/hydra/handlers/collection.ts @@ -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('@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, + ]), } } } diff --git a/packages/hydra/index.ts b/packages/hydra/index.ts index e36dab9..647daf2 100644 --- a/packages/hydra/index.ts +++ b/packages/hydra/index.ts @@ -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' @@ -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 + partialCollectionStrategies?: PartialCollectionStrategy[] } declare module '@kopflos-cms/core' { @@ -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, KopflosEnvironment> + readonly partialCollectionStrategies: PartialCollectionStrategy[] +} + +export default (options : Options): KopflosPluginConstructor => { + return class implements HydraPlugin { + readonly env: DerivedEnvironment, 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) + } } } diff --git a/packages/hydra/lib/iriTemplate.ts b/packages/hydra/lib/iriTemplate.ts new file mode 100644 index 0000000..b2efbbb --- /dev/null +++ b/packages/hydra/lib/iriTemplate.ts @@ -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 = /^"(?.+)"(@|\^\^)?((?<=@)(?.*))?((?<=\^\^)(?.*))?$/ + +function createTermFromVariable(rdf: Environment, 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, 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() +} diff --git a/packages/hydra/lib/number.ts b/packages/hydra/lib/number.ts new file mode 100644 index 0000000..5c0031d --- /dev/null +++ b/packages/hydra/lib/number.ts @@ -0,0 +1,18 @@ +import type { MultiPointer } from 'clownface' +import { isLiteral } from 'is-graph-pointer' + +export function tryParse(pointer: MultiPointer, err?: E) { + if (!isLiteral(pointer)) { + if (typeof err === 'number') { + return err + } + + throw err || new Error('Expected a literal') + } + + try { + return parseInt(pointer.value, 10) + } catch { + throw err || new Error('Expected a literal') + } +} diff --git a/packages/hydra/lib/partialCollection/index.ts b/packages/hydra/lib/partialCollection/index.ts new file mode 100644 index 0000000..2e73e31 --- /dev/null +++ b/packages/hydra/lib/partialCollection/index.ts @@ -0,0 +1,32 @@ +import type { GraphPointer } from 'clownface' + +interface LimitOffset { + limit: number + offset: number +} + +interface GetLimitOffset { + collection: GraphPointer + query: GraphPointer +} + +interface ViewLinksTemplateParams { + collection: GraphPointer + query: GraphPointer + totalItems: number +} + +export interface PrepareExpansionModel { + (arg: ViewLinksTemplateParams): GraphPointer +} + +export interface PartialCollectionStrategy { + isApplicableTo(collection: GraphPointer): boolean + getLimitOffset(arg: GetLimitOffset): LimitOffset + viewLinksTemplateParams: { + first: PrepareExpansionModel + last: PrepareExpansionModel + next: PrepareExpansionModel + previous: PrepareExpansionModel + } +} diff --git a/packages/hydra/lib/partialCollection/limitOffsetStrategy.ts b/packages/hydra/lib/partialCollection/limitOffsetStrategy.ts new file mode 100644 index 0000000..5f54721 --- /dev/null +++ b/packages/hydra/lib/partialCollection/limitOffsetStrategy.ts @@ -0,0 +1,66 @@ +import { isGraphPointer } from 'is-graph-pointer' +import { hydra } from '@tpluscode/rdf-ns-builders' +import error from 'http-errors' +import { tryParse } from '../number.js' +import type { PartialCollectionStrategy } from './index.js' + +export default { + isApplicableTo(collection) { + const hasLimitParam = isGraphPointer(collection + .out(hydra.search) + .out(hydra.mapping) + .has(hydra.property, hydra.limit)) + const hasOffsetParam = isGraphPointer(collection + .out(hydra.search) + .out(hydra.mapping) + .has(hydra.property, hydra.offset)) + + return hasLimitParam && hasOffsetParam + }, + getLimitOffset({ query }) { + let limit: number | undefined + let offset: number | undefined + const limitParam = query.out(hydra.limit) + const offsetParam = query.out(hydra.offset) + + if (isGraphPointer(limitParam)) { + limit = tryParse(limitParam, new error.BadRequest('Invalid hydra:limit')) + } + if (isGraphPointer(offsetParam)) { + offset = tryParse(offsetParam, new error.BadRequest('Invalid hydra:offset')) + } + + return { + limit, + offset, + } + }, + viewLinksTemplateParams: { + first({ query }) { + return query + .deleteOut(hydra.offset) + .addOut(hydra.offset, 0) + }, + last({ query, collection, totalItems }) { + const limit = tryParse(query.out(hydra.limit), tryParse(collection.out(hydra.limit))) + const lastOffset = Math.floor(totalItems / limit) * limit + return query + .deleteOut(hydra.offset) + .addOut(hydra.offset, lastOffset) + }, + next({ query, collection }) { + const offset = tryParse(query.out(hydra.offset), 0) + const limit = tryParse(query.out(hydra.limit), tryParse(collection.out(hydra.limit))) + return query + .deleteOut(hydra.offset) + .addOut(hydra.offset, offset + limit) + }, + previous({ query, collection }) { + const offset = tryParse(query.out(hydra.offset), 0) + const limit = tryParse(query.out(hydra.limit), tryParse(collection.out(hydra.limit))) + return query + .deleteOut(hydra.offset) + .addOut(hydra.offset, offset - limit) + }, + }, +} diff --git a/packages/hydra/lib/partialCollection/pageIndexStrategy.ts b/packages/hydra/lib/partialCollection/pageIndexStrategy.ts new file mode 100644 index 0000000..1113080 --- /dev/null +++ b/packages/hydra/lib/partialCollection/pageIndexStrategy.ts @@ -0,0 +1,54 @@ +import { isGraphPointer, isLiteral } from 'is-graph-pointer' +import { hydra } from '@tpluscode/rdf-ns-builders' +import error from 'http-errors' +import { tryParse } from '../number.js' +import type { PartialCollectionStrategy } from './index.js' + +export default { + isApplicableTo(collection) { + return isGraphPointer(collection + .out(hydra.search) + .out(hydra.mapping) + .has(hydra.property, hydra.pageIndex)) + }, + getLimitOffset({ collection, query }) { + const pageIndexParam = query.out(hydra.pageIndex) + const limitParam = collection.out(hydra.limit) + + const pageIndex = isLiteral(pageIndexParam) + ? tryParse(pageIndexParam, new error.BadRequest('Invalid hydra:pageIndex')) + : 0 + const limit = tryParse(limitParam, new Error('Invalid hydra:limit')) + + return { + limit, + offset: pageIndex * limit, + } + }, + viewLinksTemplateParams: { + first({ query }) { + return query + .deleteOut(hydra.pageIndex) + .addOut(hydra.pageIndex, 1) + }, + last({ query, collection, totalItems }) { + const currentPageIndex = tryParse(query.out(hydra.limit), tryParse(collection.out(hydra.limit))) + const lastPageIndex = Math.floor(totalItems / currentPageIndex) - 1 + return query + .deleteOut(hydra.pageIndex) + .addOut(hydra.pageIndex, lastPageIndex) + }, + next({ query }) { + const pageIndex = tryParse(query.out(hydra.pageIndex), 1) + return query + .deleteOut(hydra.pageIndex) + .addOut(hydra.pageIndex, pageIndex + 1) + }, + previous({ query }) { + const pageIndex = tryParse(query.out(hydra.pageIndex), 1) + return query + .deleteOut(hydra.pageIndex) + .addOut(hydra.pageIndex, pageIndex - 1) + }, + }, +} diff --git a/packages/hydra/package.json b/packages/hydra/package.json index 43a656a..4d180d5 100644 --- a/packages/hydra/package.json +++ b/packages/hydra/package.json @@ -22,8 +22,11 @@ "dependencies": { "@hydrofoil/shape-to-query": "^0.13.6", "@kopflos-cms/core": "^0.3.1", + "@rdfine/hydra": "^0.10.5", "@sindresorhus/merge-streams": "^4.0.0", "@tpluscode/rdf-ns-builders": "^4.3.0", + "@tpluscode/rdfine": "^0.7.10", + "@zazuko/env": "^2.5.1", "http-errors": "^2.0.0", "is-graph-pointer": "^2.1.0", "rdf-literal": "^1.3.2" diff --git a/packages/hydra/test/collection.test.ts b/packages/hydra/test/collection.test.ts index e0acf1e..45f6b7d 100644 --- a/packages/hydra/test/collection.test.ts +++ b/packages/hydra/test/collection.test.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs' -import type { Body, KopflosConfig } from '@kopflos-cms/core' +import type { Body, KopflosConfig, ResultEnvelope } from '@kopflos-cms/core' import Kopflos from '@kopflos-cms/core' import { createStore } from 'mocha-chai-rdf/store.js' import $rdf from '@zazuko/env-node' @@ -114,6 +114,179 @@ describe('@kopflos-cms/hydra', () => { }) }) + context('collection paged', () => { + let res: ResultEnvelope + + context('with limit/offset', () => { + const collection = ex['municipalities/limit-offset'] + + context('limit and offset given', () => { + beforeEach(async function () { + // given + const kopflos = await startKopflos() + + // when + res = await kopflos.handleRequest({ + method: 'GET', + iri: collection, + headers: {}, + query: { + limit: '10', + offset: '40', + }, + body: {} as Body, + }) + }) + + it('returns subset of results', async function () { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.member).terms).to.have.length(10) + expect(pointer.out(ns.hydra.totalItems).term).to.deep.eq(toRdf(3449)) + }) + + it('includes next page link', async () => { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.view).out(ns.hydra.next).term) + .to.deep.eq(ex['municipalities/limit-offset?limit=10&offset=50']) + }) + + it('includes previous page link', async () => { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.view).out(ns.hydra.previous).term) + .to.deep.eq(ex['municipalities/limit-offset?limit=10&offset=30']) + }) + + it('includes first page link', async () => { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.view).out(ns.hydra.first).term) + .to.deep.eq(ex['municipalities/limit-offset?limit=10&offset=0']) + }) + + it('includes last page link', async () => { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.view).out(ns.hydra.last).term) + .to.deep.eq(ex['municipalities/limit-offset?limit=10&offset=3440']) + }) + }) + + context('only limit given', () => { + beforeEach(async function () { + // given + const kopflos = await startKopflos() + + // when + res = await kopflos.handleRequest({ + method: 'GET', + iri: collection, + headers: {}, + query: { + limit: '10', + }, + body: {} as Body, + }) + }) + + it('returns subset of results', async function () { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.member).terms).to.have.length(10) + expect(pointer.out(ns.hydra.totalItems).term).to.deep.eq(toRdf(3449)) + }) + }) + + context('only offset given', () => { + beforeEach(async function () { + // given + const kopflos = await startKopflos() + + // when + res = await kopflos.handleRequest({ + method: 'GET', + iri: collection, + headers: {}, + query: { + offset: '10', + }, + body: {} as Body, + }) + }) + + it('returns subset of results', async function () { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.member).terms).to.have.length(3439) + expect(pointer.out(ns.hydra.totalItems).term).to.deep.eq(toRdf(3449)) + }) + }) + }) + + context('with page index', () => { + const collection = ex['municipalities/paged'] + + context('page index given', () => { + beforeEach(async function () { + // given + const kopflos = await startKopflos() + + // when + res = await kopflos.handleRequest({ + method: 'GET', + iri: collection, + headers: {}, + query: { + page: '10', + }, + body: {} as Body, + }) + }) + + it('returns subset of results', async function () { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.member).terms).to.have.length(20) + expect(pointer.out(ns.hydra.totalItems).term).to.deep.eq(toRdf(3449)) + }) + }) + + context('page index not given', () => { + beforeEach(async function () { + // given + const kopflos = await startKopflos() + + // when + res = await kopflos.handleRequest({ + method: 'GET', + iri: collection, + headers: {}, + query: {}, + body: {} as Body, + }) + }) + + it('returns subset of results', async function () { + // then + const dataset = await $rdf.dataset().import(res.body as Stream) + const pointer = $rdf.clownface({ dataset }).node(collection) + expect(pointer.out(ns.hydra.member).terms).to.have.length(20) + expect(pointer.out(ns.hydra.totalItems).term).to.deep.eq(toRdf(3449)) + }) + }) + }) + }) + context('when collection has multiple memberAssertion', () => { it('should return a stream of members', async function () { // given diff --git a/packages/hydra/test/collection.test.ts.trig b/packages/hydra/test/collection.test.ts.trig index a52f310..efb7ca4 100644 --- a/packages/hydra/test/collection.test.ts.trig +++ b/packages/hydra/test/collection.test.ts.trig @@ -57,6 +57,60 @@ GRAPH { . } +GRAPH { + + a hydra:Collection ; + hydra:memberAssertion + [ + hydra:property rdf:type ; + hydra:object ; + ] ; + kl-hydra:memberShape + [ + sh:property [ sh:path schema:name ] ; + ] ; + hydra:limit 10 ; + hydra:search + [ + hydra:template "{?limit,offset}" ; + hydra:mapping + [ + hydra:variable "limit" ; + hydra:property hydra:limit ; + ], + [ + hydra:variable "offset" ; + hydra:property hydra:offset ; + ] ; + ] ; + . +} + +GRAPH { + + a hydra:Collection ; + hydra:memberAssertion + [ + hydra:property rdf:type ; + hydra:object ; + ] ; + kl-hydra:memberShape + [ + sh:property [ sh:path schema:name ] ; + ] ; + hydra:limit 20 ; + hydra:search + [ + hydra:template "{?page}" ; + hydra:mapping + [ + hydra:variable "page" ; + hydra:property hydra:pageIndex ; + ] ; + ] ; + . +} + GRAPH { a hydra:Collection ; From 3370532e218ae1a6b71d18b62128e74170b15318 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Tue, 14 Jan 2025 09:18:02 +0100 Subject: [PATCH 02/18] refactor: class plugins --- .changeset/tidy-mice-tell.md | 7 ++++++ packages/cli/lib/command/build.ts | 2 +- packages/core/lib/Kopflos.ts | 4 +-- packages/core/plugin/shorthandTerms.ts | 15 +++++++----- packages/core/plugins.ts | 4 +-- packages/core/test/lib/Kopflos.test.ts | 16 ++++++------ packages/express/plugin/middleware.ts | 21 ++++++++++------ packages/vite/index.ts | 34 +++++++++++++++++--------- 8 files changed, 65 insertions(+), 38 deletions(-) create mode 100644 .changeset/tidy-mice-tell.md diff --git a/.changeset/tidy-mice-tell.md b/.changeset/tidy-mice-tell.md new file mode 100644 index 0000000..dba9bd1 --- /dev/null +++ b/.changeset/tidy-mice-tell.md @@ -0,0 +1,7 @@ +--- +"@kopflos-cms/express": patch +"@kopflos-cms/vite": patch +"kopflos": patch +--- + +Plugins are now implemented as classes diff --git a/packages/cli/lib/command/build.ts b/packages/cli/lib/command/build.ts index 5c4a2f6..04a9196 100644 --- a/packages/cli/lib/command/build.ts +++ b/packages/cli/lib/command/build.ts @@ -13,7 +13,7 @@ export default async function (args: BuildArgs) { const plugins = await loadPlugins(config.plugins) log.info('Running build actions...') - const buildActions = plugins.map(plugin => plugin.build?.()) + const buildActions = plugins.map(Plugin => Plugin.build?.()) if (buildActions.length === 0) { return log.warn('No plugins with build actions found') } else { diff --git a/packages/core/lib/Kopflos.ts b/packages/core/lib/Kopflos.ts index b5e10f1..a90d6c7 100644 --- a/packages/core/lib/Kopflos.ts +++ b/packages/core/lib/Kopflos.ts @@ -61,8 +61,7 @@ export interface PluginConfig { } export interface KopflosPlugin { - readonly name: string - build?: () => Promise | void + readonly name?: string onStart?(): Promise | void onStop?(): Promise | void apiTriples?(): Promise | DatasetCore | Stream @@ -82,6 +81,7 @@ export interface Kopflos { export interface KopflosPluginConstructor { new(instance: Kopflos): KopflosPlugin + build?: () => Promise | void } interface Clients { diff --git a/packages/core/plugin/shorthandTerms.ts b/packages/core/plugin/shorthandTerms.ts index 1b5dbd0..7b87d10 100644 --- a/packages/core/plugin/shorthandTerms.ts +++ b/packages/core/plugin/shorthandTerms.ts @@ -1,11 +1,14 @@ import type { Stream } from '@rdfjs/types' -import type { KopflosPlugin } from '../lib/Kopflos.js' +import type { Kopflos, KopflosPlugin, KopflosPluginConstructor } from '../lib/Kopflos.js' -export default function (): KopflosPlugin { - return { - apiTriples(kopflos): Stream { - const { env } = kopflos +export default function (): KopflosPluginConstructor { + return class implements KopflosPlugin { + constructor(private readonly kopflos: Kopflos) { + } + + apiTriples(): Stream { + const { env } = this.kopflos return env.fromFile(new URL('../graphs/shorthands.ttl', import.meta.url)) - }, + } } } diff --git a/packages/core/plugins.ts b/packages/core/plugins.ts index d9e3d65..a7e18bb 100644 --- a/packages/core/plugins.ts +++ b/packages/core/plugins.ts @@ -1,7 +1,7 @@ import log from './lib/log.js' -import type { KopflosConfig, KopflosPlugin } from './lib/Kopflos.js' +import type { KopflosConfig, KopflosPluginConstructor } from './lib/Kopflos.js' -export async function loadPlugins(plugins: KopflosConfig['plugins']): Promise { +export async function loadPlugins(plugins: KopflosConfig['plugins']): Promise { const pluginsCombined = Object.entries({ '@kopflos-cms/core/plugin/shorthandTerms.js': {}, ...plugins, diff --git a/packages/core/test/lib/Kopflos.test.ts b/packages/core/test/lib/Kopflos.test.ts index 4a85d2e..c5f0dbd 100644 --- a/packages/core/test/lib/Kopflos.test.ts +++ b/packages/core/test/lib/Kopflos.test.ts @@ -620,8 +620,9 @@ describe('lib/Kopflos', () => { it('calls onStart on plugins once', async function () { // given - const plugin = { - onStart: sinon.spy(), + const onStart = sinon.spy() + const plugin = class { + onStart = onStart } const instance = new Kopflos({ ...config, @@ -636,15 +637,16 @@ describe('lib/Kopflos', () => { await instance.start() // then - expect(plugin.onStart).to.have.been.calledOnce + expect(onStart).to.have.been.calledOnce }) }) describe('stop', () => { it('calls onStop on plugins', async function () { // given - const plugin = { - onStop: sinon.spy(), + const onStop = sinon.spy() + const plugin = class { + onStop = onStop } const instance = new Kopflos({ ...config, @@ -659,12 +661,12 @@ describe('lib/Kopflos', () => { await instance.stop() // then - expect(plugin.onStop).to.have.been.called + expect(onStop).to.have.been.called }) it('ignores plugins without onStop', async function () { // given - const plugin = {} + const plugin = class {} const instance = new Kopflos({ ...config, sparql: { diff --git a/packages/express/plugin/middleware.ts b/packages/express/plugin/middleware.ts index 3fa1718..09756c1 100644 --- a/packages/express/plugin/middleware.ts +++ b/packages/express/plugin/middleware.ts @@ -1,4 +1,4 @@ -import type { KopflosPlugin } from '@kopflos-cms/core' +import type { KopflosPlugin, KopflosPluginConstructor } from '@kopflos-cms/core' import type { Router } from 'express' import { createLogger } from '@kopflos-cms/logger' @@ -17,9 +17,14 @@ declare module '@kopflos-cms/core' { } } -export default function ({ before = [], after = [] }: Options): KopflosPlugin { - function use(middlewares: Array) { - return async function (host: Router) { +export default function ({ before = [], after = [] }: Options): KopflosPluginConstructor { + return class implements KopflosPlugin { + readonly name = '@kopflos-cms/express/middleware' + + declare beforeMiddleware: (host: Router) => Promise + declare afterMiddleware: (host: Router) => Promise + + private async use(middlewares: Array, host: Router) { const promises = middlewares.map(async middleware => { let module: string let options: unknown | undefined @@ -41,10 +46,10 @@ export default function ({ before = [], after = [] }: Options): KopflosPlugin { current = promises.splice(0, 1) } } - } - return { - beforeMiddleware: use(before), - afterMiddleware: use(after), + constructor() { + this.beforeMiddleware = this.use.bind(null, before) + this.afterMiddleware = this.use.bind(null, after) + } } } diff --git a/packages/vite/index.ts b/packages/vite/index.ts index 68b2803..a606407 100644 --- a/packages/vite/index.ts +++ b/packages/vite/index.ts @@ -1,5 +1,5 @@ import { resolve } from 'node:path' -import type { Kopflos, KopflosPlugin } from '@kopflos-cms/core' +import type { Kopflos, KopflosEnvironment, KopflosPlugin, KopflosPluginConstructor } from '@kopflos-cms/core' import express from 'express' import { build } from 'vite' import { createViteServer } from './lib/server.js' @@ -21,20 +21,29 @@ declare module '@kopflos-cms/core' { } } -export default function ({ outDir = 'dist', ...options }: Options): KopflosPlugin { +export default function ({ outDir = 'dist', ...options }: Options): KopflosPluginConstructor { const rootDir = resolve(process.cwd(), options.root || '') const buildDir = resolve(process.cwd(), outDir) - return { - onStart({ env }: Kopflos): Promise | void { + return class implements KopflosPlugin { + readonly name = '@kopflos-cms/vite' + + private env: KopflosEnvironment + + constructor(instance: Kopflos) { + this.env = instance.env + } + + onStart(): Promise | void { const viteVars = { - basePath: env.kopflos.config.mode === 'development' ? rootDir : buildDir, + basePath: this.env.kopflos.config.mode === 'development' ? rootDir : buildDir, } log.info('Variables', viteVars) - env.kopflos.variables.VITE = Object.freeze(viteVars) - }, - async beforeMiddleware(host: express.Router, { env }) { - if (env.kopflos.config.mode === 'development') { + this.env.kopflos.variables.VITE = Object.freeze(viteVars) + } + + async beforeMiddleware(host: express.Router) { + if (this.env.kopflos.config.mode === 'development') { log.info('Development UI mode. Creating Vite server...') const viteServer = await createViteServer(options) host.use(viteServer.middlewares) @@ -43,10 +52,11 @@ export default function ({ outDir = 'dist', ...options }: Options): KopflosPlugi log.debug('Build directory:', buildDir) host.use(express.static(buildDir)) } - }, - async build() { + } + + static async build() { log.info('Building UI...') await build(await prepareConfig({ outDir, ...options })) - }, + } } } From f659123052d82eabe758034cf20b0b3455ad60f6 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Tue, 14 Jan 2025 14:55:38 +0100 Subject: [PATCH 03/18] chore: update lock --- package-lock.json | 81 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5db457..6186c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7265,6 +7265,43 @@ "node": ">=14" } }, + "node_modules/@rdfine/hydra": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@rdfine/hydra/-/hydra-0.10.5.tgz", + "integrity": "sha512-vYn7ElBRFuZ+7xbkogCRMPW4X4PnYUEYtV4q6YFfl+jzuEDyvnfRmxOJrOxTFut4k5zRHIQhgNG6WzT47PStqA==", + "license": "MIT", + "dependencies": { + "@rdfine/rdf": "^0.7.5", + "@rdfine/rdfs": "^0.8.5", + "@rdfjs/data-model": "^2", + "@tpluscode/rdf-ns-builders": "^4.3.0", + "@tpluscode/rdfine": ">=0.7.7", + "es6-url-template": "^3.0.2" + } + }, + "node_modules/@rdfine/rdf": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@rdfine/rdf/-/rdf-0.7.5.tgz", + "integrity": "sha512-JCmpZFTvh0+R0Jwnsgw2aQ9DLKl0l/9cMtC8NiRlg93HODwN+yK6wCuf/uluzv5VuHt2V9RSYRK60ZtDk+boGA==", + "license": "MIT", + "dependencies": { + "@rdfjs/data-model": "^2", + "@tpluscode/rdf-ns-builders": "^4.3.0", + "@tpluscode/rdfine": ">=0.7.7" + } + }, + "node_modules/@rdfine/rdfs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@rdfine/rdfs/-/rdfs-0.8.5.tgz", + "integrity": "sha512-313FrvSNX2O0BfBoxaPwalY5gL04k8+BoTcebgwBk0+VBCQYjAW1O1ykCxPLDdjTAB5Heu/PRFUhd7Dpom87rg==", + "license": "MIT", + "dependencies": { + "@rdfine/rdf": "^0.7.5", + "@rdfjs/data-model": "^2", + "@tpluscode/rdf-ns-builders": "^4.3.0", + "@tpluscode/rdfine": ">=0.7.7" + } + }, "node_modules/@rdfjs-elements/formats-pretty": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/@rdfjs-elements/formats-pretty/-/formats-pretty-0.6.8.tgz", @@ -7952,6 +7989,37 @@ "@zazuko/prefixes": ">=1" } }, + "node_modules/@tpluscode/rdfine": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@tpluscode/rdfine/-/rdfine-0.7.10.tgz", + "integrity": "sha512-Auf5uKocaaWWTugErWvA+5rjgtB2oJ3f8ipvdGz0QGTkBLoJ59RwiM8ly2AMd/iFR9a1LtmmkHS2t2kjEXXLJA==", + "license": "MIT", + "dependencies": { + "@rdfjs/data-model": "^2.0.1", + "@rdfjs/environment": "0 - 1", + "@rdfjs/namespace": "^2.0.0", + "@rdfjs/types": ">=1.1.0", + "@types/clownface": "^2", + "@types/rdfjs__data-model": "^2.0.7", + "@types/rdfjs__traverser": "^0.1.5", + "onetime": "^7.0.0" + } + }, + "node_modules/@tpluscode/rdfine/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@tpluscode/sparql-builder": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@tpluscode/sparql-builder/-/sparql-builder-3.0.0.tgz", @@ -8315,7 +8383,6 @@ "resolved": "https://registry.npmjs.org/@types/rdfjs__data-model/-/rdfjs__data-model-2.0.8.tgz", "integrity": "sha512-7OVjhmA8QPEdRReHFieKuqn2mbYx3ndEIEmh/6FkeJC8QCMJGVeSuRKEUVXbZGwP0rDKZuhQGozaRv3O1z1gPQ==", "license": "MIT", - "peer": true, "dependencies": { "@rdfjs/types": "^1.0.1" } @@ -8498,7 +8565,6 @@ "resolved": "https://registry.npmjs.org/@types/rdfjs__traverser/-/rdfjs__traverser-0.1.5.tgz", "integrity": "sha512-tTpiM6lAddw+bGRDjhzwdpo1EQK73m8gYgMVNfO4OsevnuLZvQJeCJBckpuDC4H5HVAEwCapI0UlH9dVnZ9u5g==", "license": "MIT", - "peer": true, "dependencies": { "@rdfjs/types": "*" } @@ -11466,6 +11532,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-url-template": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/es6-url-template/-/es6-url-template-3.0.2.tgz", + "integrity": "sha512-8ZqWPZ8aa9FoyZlGlC+s/d84VW7glQA3WwvDTl0Ykmwi/fX1aEmd6RWoBprX7QWzwR5zrv8RIokfj6kLmcdYzA==", + "license": "BSD" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -20592,13 +20664,18 @@ "dependencies": { "@hydrofoil/shape-to-query": "^0.13.6", "@kopflos-cms/core": "^0.3.1", + "@rdfine/hydra": "^0.10.5", "@sindresorhus/merge-streams": "^4.0.0", "@tpluscode/rdf-ns-builders": "^4.3.0", + "@tpluscode/rdfine": "^0.7.10", + "@zazuko/env": "^2.5.1", + "http-errors": "^2.0.0", "is-graph-pointer": "^2.1.0", "rdf-literal": "^1.3.2" }, "devDependencies": { "@rdfjs-elements/formats-pretty": "^0.6.8", + "@types/http-errors": "^2.0.4", "@zazuko/env-node": "^2.1.4", "chai": "^5.1.2", "mocha-chai-rdf": "^0.1.6" From 83420d9b7aab5af54d98ab0293e8293f5f27e07e Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Tue, 14 Jan 2025 16:14:10 +0100 Subject: [PATCH 04/18] test: explicit variable representation --- packages/hydra/lib/iriTemplate.ts | 2 +- packages/hydra/test/lib/iriTemplate.test.ts | 108 ++++++++++++++++++ .../hydra/test/lib/iriTemplate.test.ts.trig | 45 ++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/hydra/test/lib/iriTemplate.test.ts create mode 100644 packages/hydra/test/lib/iriTemplate.test.ts.trig diff --git a/packages/hydra/lib/iriTemplate.ts b/packages/hydra/lib/iriTemplate.ts index b2efbbb..6df9dca 100644 --- a/packages/hydra/lib/iriTemplate.ts +++ b/packages/hydra/lib/iriTemplate.ts @@ -26,7 +26,7 @@ function createTermFromVariable(rdf: Environment, template: GraphPo return rdf.namedNode(value) } - const values = Array.isArray(value) ? value : [value] + const values = Array.isArray(value) ? value : value.split(',') return values.map(parseValue) } diff --git a/packages/hydra/test/lib/iriTemplate.test.ts b/packages/hydra/test/lib/iriTemplate.test.ts new file mode 100644 index 0000000..72f9657 --- /dev/null +++ b/packages/hydra/test/lib/iriTemplate.test.ts @@ -0,0 +1,108 @@ +import { parse } from 'node:querystring' +import { hydra, schema, xsd } from '@tpluscode/rdf-ns-builders' +import $rdf from '@zazuko/env-node' +import { expect } from 'chai' +import { createStore } from 'mocha-chai-rdf/store.js' +import { fromQuery } from '../../lib/iriTemplate.js' +import { ex } from '../../../testing-helpers/ns.js' + +describe('@kopflos-cms/hydra/lib/iriTemplate.js', () => { + beforeEach(createStore(import.meta.url, { + format: 'trig', + sliceTestPath: [2, -1], + })) + + describe('fromQuery', () => { + context('explicit variable representation', () => { + it('should parse unquoted template variable as named node', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/id')) + const queryString = parse('id=http%3A%2F%2Fwww.hydra-cg.com%2F') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(schema.identifier)).to.deep.eq($rdf.namedNode('http://www.hydra-cg.com/')) + }) + + it('should parse quoted template variable as literal', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/find')) + const queryString = parse('find=%22A%20simple%20string%22') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(hydra.freetextQuery)).to.deep.eq($rdf.literal('A simple string')) + }) + + it('should parse literal with quotes inside', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/find')) + const queryString = parse('find=%22A%20string%20%22%20with%20a%20quote%22') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(hydra.freetextQuery)).to.deep.eq($rdf.literal('A string " with a quote')) + }) + + it('should parse typed literal', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/width')) + const queryString = parse('width=%225.5%22%5E%5Ehttp%3A%2F%2Fwww.w3.org%2F2001%2FXMLSchema%23decimal') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(schema.width)).to.deep.eq($rdf.literal('5.5', xsd.decimal)) + }) + + it('should parse tagged literal', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/find')) + const queryString = parse('find=%22A%20simple%20string%22%40en') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(hydra.freetextQuery)).to.deep.eq($rdf.literal('A simple string', 'en')) + }) + + it('should return individual object for comma-separated query values', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/tag')) + const queryString = parse('tag=http%3A%2F%2Fexample.org%2Fdimension%2Fcolors,http%3A%2F%2Fexample.org%2Fdimension%2Fcountries') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(ex.tag).terms).to.deep.eq([ + $rdf.namedNode('http://example.org/dimension/colors'), + $rdf.namedNode('http://example.org/dimension/countries'), + ]) + }) + + it('should return individual object for repeated query values', async function () { + // given + const template = this.rdf.graph.node(ex('query-only/tag')) + const queryString = parse('tag=http%3A%2F%2Fexample.org%2Fdimension%2Fcolors&tag=http%3A%2F%2Fexample.org%2Fdimension%2Fcountries') + + // when + const query = fromQuery($rdf, queryString, template) + + // then + expect(query.out(ex.tag).terms).to.deep.eq([ + $rdf.namedNode('http://example.org/dimension/colors'), + $rdf.namedNode('http://example.org/dimension/countries'), + ]) + }) + }) + }) +}) diff --git a/packages/hydra/test/lib/iriTemplate.test.ts.trig b/packages/hydra/test/lib/iriTemplate.test.ts.trig new file mode 100644 index 0000000..bad435f --- /dev/null +++ b/packages/hydra/test/lib/iriTemplate.test.ts.trig @@ -0,0 +1,45 @@ +PREFIX schema: +PREFIX ex: +PREFIX hydra: + +GRAPH { + ex:query-only\/id + hydra:template "{?id}" ; + hydra:variableRepresentation hydra:ExplicitRepresentation ; + hydra:mapping + [ + hydra:variable "id" ; + hydra:property schema:identifier ; + ] ; + . + + ex:query-only\/find + hydra:template "{?find}" ; + hydra:variableRepresentation hydra:ExplicitRepresentation ; + hydra:mapping + [ + hydra:variable "find" ; + hydra:property hydra:freetextQuery ; + ] ; + . + + ex:query-only\/width + hydra:template "{?width}" ; + hydra:variableRepresentation hydra:ExplicitRepresentation ; + hydra:mapping + [ + hydra:variable "width" ; + hydra:property schema:width ; + ] ; + . + + ex:query-only\/tag + hydra:template "{?tag}" ; + hydra:variableRepresentation hydra:ExplicitRepresentation ; + hydra:mapping + [ + hydra:variable "tag" ; + hydra:property ex:tag ; + ] ; + . +} From 0cdb2babceae5d00b0f96243473a03cde35891f9 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Tue, 14 Jan 2025 16:26:51 +0100 Subject: [PATCH 05/18] test: applying template to resource URL --- packages/hydra/lib/iriTemplate.ts | 12 +-- packages/hydra/test/lib/iriTemplate.test.ts | 106 +++++++++++++++++++- 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/packages/hydra/lib/iriTemplate.ts b/packages/hydra/lib/iriTemplate.ts index 6df9dca..2dd68e0 100644 --- a/packages/hydra/lib/iriTemplate.ts +++ b/packages/hydra/lib/iriTemplate.ts @@ -54,17 +54,17 @@ export function fromQuery(rdf: Environment, quer return templateParams } -export function combineTemplate(collection: GraphPointer, expanded: string) { - let collectionURL = new URL(collection.value) +export function applyTemplate(resource: GraphPointer, expanded: string) { + let url = new URL(resource.value) - if (!expanded.startsWith('?') || expanded.startsWith('$')) { + if (expanded.startsWith('?') || expanded.startsWith('$')) { const searchParams = new URLSearchParams(expanded) for (const [param, value] of searchParams) { - collectionURL.searchParams.append(param, value) + url.searchParams.append(param, value) } } else { - collectionURL = new URL(expanded, collectionURL) + url = new URL(expanded, url) } - return collectionURL.toString() + return url.toString() } diff --git a/packages/hydra/test/lib/iriTemplate.test.ts b/packages/hydra/test/lib/iriTemplate.test.ts index 72f9657..c462a94 100644 --- a/packages/hydra/test/lib/iriTemplate.test.ts +++ b/packages/hydra/test/lib/iriTemplate.test.ts @@ -3,7 +3,7 @@ import { hydra, schema, xsd } from '@tpluscode/rdf-ns-builders' import $rdf from '@zazuko/env-node' import { expect } from 'chai' import { createStore } from 'mocha-chai-rdf/store.js' -import { fromQuery } from '../../lib/iriTemplate.js' +import { fromQuery, applyTemplate } from '../../lib/iriTemplate.js' import { ex } from '../../../testing-helpers/ns.js' describe('@kopflos-cms/hydra/lib/iriTemplate.js', () => { @@ -105,4 +105,108 @@ describe('@kopflos-cms/hydra/lib/iriTemplate.js', () => { }) }) }) + + describe('applyTemplate', () => { + context('template is only query params', () => { + it('sets params to resource', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo/bar') + + // when + const result = applyTemplate(resource, '?baz=qux') + + // then + expect(result).to.eq('http://example.com/foo/bar?baz=qux') + }) + + it('replaces existing params', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo?baz=bar') + + // when + const result = applyTemplate(resource, '?baz=qux') + + // then + expect(result).to.eq('http://example.com/foo?baz=qux') + }) + + it('combines with existing params', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo?bar=bar') + + // when + const result = applyTemplate(resource, '?baz=baz') + + // then + expect(result).to.eq('http://example.com/foo?bar=bar&baz=baz') + }) + }) + + context('template is partial query params', () => { + it('sets params to resource', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo/bar') + + // when + const result = applyTemplate(resource, '&baz=qux') + + // then + expect(result).to.eq('http://example.com/foo/bar?baz=qux') + }) + + it('replaces params', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo?bar=bar') + + // when + const result = applyTemplate(resource, '&bar=qux') + + // then + expect(result).to.eq('http://example.com/foo?bar=qux') + }) + + it('combines with other params', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo?bar=bar') + + // when + const result = applyTemplate(resource, '&baz=qux') + + // then + expect(result).to.eq('http://example.com/foo?bar=bar&baz=qux') + }) + }) + + context('template is path', () => { + it('creates a URL from absolute path', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo/bar') + + // when + const result = applyTemplate(resource, '/baz') + + // then + expect(result).to.eq('http://example.com/baz') + }) + + it('creates a URL from relative path', () => { + // given + const resource = $rdf.clownface() + .namedNode('http://example.com/foo/bar') + + // when + const result = applyTemplate(resource, './baz') + + // then + expect(result).to.eq('http://example.com/foo/baz') + }) + }) + }) }) From 935e1fa2efe9419e620cbca11f22cac5b2a7cc52 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 09:38:35 +0100 Subject: [PATCH 06/18] refactor: finding plugin by name --- packages/core/index.ts | 2 +- packages/core/lib/Kopflos.ts | 9 ++++++--- packages/express/plugin/middleware.ts | 2 -- packages/hydra/handlers/collection.ts | 7 +++---- packages/hydra/index.ts | 12 ++++++++---- packages/plugin-deploy-resources/index.ts | 21 +++++++++++++-------- packages/vite/index.ts | 2 -- 7 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/core/index.ts b/packages/core/index.ts index c5effdf..c67bae8 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -1,4 +1,4 @@ -export type { KopflosResponse, KopflosPlugin, PluginConfig, ResultEnvelope, KopflosPluginConstructor } from './lib/Kopflos.js' +export type { KopflosResponse, KopflosPlugin, PluginConfig, ResultEnvelope, KopflosPluginConstructor, Plugins } 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' diff --git a/packages/core/lib/Kopflos.ts b/packages/core/lib/Kopflos.ts index a90d6c7..58c47f5 100644 --- a/packages/core/lib/Kopflos.ts +++ b/packages/core/lib/Kopflos.ts @@ -67,6 +67,9 @@ export interface KopflosPlugin { apiTriples?(): Promise | DatasetCore | Stream } +export interface Plugins extends Record { +} + export interface Kopflos { get dataset(): D get env(): KopflosEnvironment @@ -74,7 +77,7 @@ export interface Kopflos { // eslint-disable-next-line no-use-before-define get plugins(): Array get start(): () => Promise - getPlugin

(name: N): P | undefined + getPlugin(name: N): Plugins[N] | undefined handleRequest(req: KopflosRequest): Promise loadApiGraphs(): Promise } @@ -152,8 +155,8 @@ export default class Impl implements Kopflos { return this.graph.has(this.env.ns.rdf.type, this.env.ns.kopflos.Api) } - getPlugin

(name: N) { - return this.plugins.find(plugin => plugin.name === name) as unknown as P | undefined + getPlugin(name: N): Plugins[N] | undefined { + return this.plugins.find(plugin => plugin.name === name) as Plugins[N] | undefined } async getResponse(req: KopflosRequest): Promise { diff --git a/packages/express/plugin/middleware.ts b/packages/express/plugin/middleware.ts index 09756c1..8479ae6 100644 --- a/packages/express/plugin/middleware.ts +++ b/packages/express/plugin/middleware.ts @@ -19,8 +19,6 @@ declare module '@kopflos-cms/core' { export default function ({ before = [], after = [] }: Options): KopflosPluginConstructor { return class implements KopflosPlugin { - readonly name = '@kopflos-cms/express/middleware' - declare beforeMiddleware: (host: Router) => Promise declare afterMiddleware: (host: Router) => Promise diff --git a/packages/hydra/handlers/collection.ts b/packages/hydra/handlers/collection.ts index 074cba8..5ae4542 100644 --- a/packages/hydra/handlers/collection.ts +++ b/packages/hydra/handlers/collection.ts @@ -13,16 +13,15 @@ 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 { applyTemplate, 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 async ({ instance, subject, ...req }) => { - const hydraPlugin = instance.getPlugin('@kopflos-cms/hydra') + const hydraPlugin = instance.getPlugin('hydra') if (!hydraPlugin) { throw new Error('Hydra plugin not loaded') } @@ -71,7 +70,7 @@ export function get(): Handler { function createPageLink(prepareExpansionModel: PrepareExpansionModel) { return view.namedNode( - combineTemplate(subject, templateObj.expand(prepareExpansionModel({ + applyTemplate(subject, templateObj.expand(prepareExpansionModel({ query: cloneQuery(), totalItems, collection: subject, }))), ) diff --git a/packages/hydra/index.ts b/packages/hydra/index.ts index 647daf2..b6da4dd 100644 --- a/packages/hydra/index.ts +++ b/packages/hydra/index.ts @@ -39,15 +39,19 @@ export interface HydraPlugin extends KopflosPlugin { readonly partialCollectionStrategies: PartialCollectionStrategy[] } +declare module '@kopflos-cms/core' { + interface Plugins { + hydra: HydraPlugin + } +} + export default (options : Options): KopflosPluginConstructor => { return class implements HydraPlugin { + readonly name = 'hydra' + readonly env: DerivedEnvironment, KopflosEnvironment> readonly partialCollectionStrategies: PartialCollectionStrategy[] - get name() { - return '@kopflos-cms/hydra' - } - constructor(private readonly instance: Kopflos) { this.env = new E([RdfineFactory, HydraFactory], { parent: instance.env }) this.partialCollectionStrategies = [ diff --git a/packages/plugin-deploy-resources/index.ts b/packages/plugin-deploy-resources/index.ts index 2f88804..882a0ef 100644 --- a/packages/plugin-deploy-resources/index.ts +++ b/packages/plugin-deploy-resources/index.ts @@ -1,4 +1,4 @@ -import type { Kopflos, KopflosEnvironment } from '@kopflos-cms/core' +import type { Kopflos, KopflosEnvironment, KopflosPlugin, KopflosPluginConstructor } from '@kopflos-cms/core' import { bootstrap } from '@hydrofoil/talos-core/bootstrap.js' import { fromDirectories } from '@hydrofoil/talos-core' import { ResourcePerGraphStore } from '@hydrofoil/resource-store' @@ -26,11 +26,14 @@ export async function deploy(paths: string[], env: KopflosEnvironment) { }) } -export default function kopflosPlugin({ paths = [], enabled = true, watch = true }: Options = {}) { +export default function kopflosPlugin({ paths = [], enabled = true, watch = true }: Options = {}): KopflosPluginConstructor { const instances = new WeakMap() - return { - onStart(instance: Kopflos) { + return class implements KopflosPlugin { + constructor(private instance: Kopflos) { + } + + onStart() { if (!enabled) { log.info('Auto deploy disabled. Skipping deployment') return @@ -43,6 +46,7 @@ export default function kopflosPlugin({ paths = [], enabled = true, watch = true log.info(`Auto deploy enabled. Deploying from: ${paths}`) + const instance = this.instance if (watch && instance.env.kopflos.config.watch) { async function redeploy(changedFile: string) { log.info('Resources changed, redeploying') @@ -60,10 +64,11 @@ export default function kopflosPlugin({ paths = [], enabled = true, watch = true } return deploy(paths, instance.env) - }, - async onStop(instance: Kopflos) { - const watcher = instances.get(instance) + } + + async onStop() { + const watcher = instances.get(this.instance) await watcher?.close() - }, + } } } diff --git a/packages/vite/index.ts b/packages/vite/index.ts index a606407..67b8499 100644 --- a/packages/vite/index.ts +++ b/packages/vite/index.ts @@ -26,8 +26,6 @@ export default function ({ outDir = 'dist', ...options }: Options): KopflosPlugi const buildDir = resolve(process.cwd(), outDir) return class implements KopflosPlugin { - readonly name = '@kopflos-cms/vite' - private env: KopflosEnvironment constructor(instance: Kopflos) { From 3e7181226f963fb32616d8003b695eb74fa71682 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 09:46:47 +0100 Subject: [PATCH 07/18] chore: update deploy plugin to class --- packages/plugin-deploy-resources/index.ts | 6 ++-- .../test/index.test.ts | 33 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/plugin-deploy-resources/index.ts b/packages/plugin-deploy-resources/index.ts index 882a0ef..34f0087 100644 --- a/packages/plugin-deploy-resources/index.ts +++ b/packages/plugin-deploy-resources/index.ts @@ -1,4 +1,4 @@ -import type { Kopflos, KopflosEnvironment, KopflosPlugin, KopflosPluginConstructor } from '@kopflos-cms/core' +import type { Kopflos, KopflosEnvironment, KopflosPlugin } from '@kopflos-cms/core' import { bootstrap } from '@hydrofoil/talos-core/bootstrap.js' import { fromDirectories } from '@hydrofoil/talos-core' import { ResourcePerGraphStore } from '@hydrofoil/resource-store' @@ -26,11 +26,11 @@ export async function deploy(paths: string[], env: KopflosEnvironment) { }) } -export default function kopflosPlugin({ paths = [], enabled = true, watch = true }: Options = {}): KopflosPluginConstructor { +export default function kopflosPlugin({ paths = [], enabled = true, watch = true }: Options = {}) { const instances = new WeakMap() return class implements KopflosPlugin { - constructor(private instance: Kopflos) { + constructor(public instance: Kopflos) { } onStart() { diff --git a/packages/plugin-deploy-resources/test/index.test.ts b/packages/plugin-deploy-resources/test/index.test.ts index 65b4507..dc9706a 100644 --- a/packages/plugin-deploy-resources/test/index.test.ts +++ b/packages/plugin-deploy-resources/test/index.test.ts @@ -6,6 +6,7 @@ import { createEmpty } from 'mocha-chai-rdf/store.js' import rdf from '@zazuko/env-node' import { expect, use } from 'chai' import snapshots from 'mocha-chai-rdf/snapshots.js' +import type { KopflosPlugin } from '@kopflos-cms/core' import Kopflos from '@kopflos-cms/core' import { temporaryDirectory } from 'tempy' import configure from '../index.js' @@ -33,12 +34,12 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { context('disabled', () => { beforeEach(async () => { - const plugin = configure({ + const Plugin = configure({ enabled: false, }) // when - await plugin.onStart(env) + await new Plugin(env).onStart() }) it('does nothing', function () { @@ -48,12 +49,12 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { context('no paths', () => { beforeEach(async () => { - const plugin = configure({ + const Plugin = configure({ paths: [], }) // when - await plugin.onStart(env) + await new Plugin(env).onStart() }) it('does nothing', function () { @@ -63,12 +64,12 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { context('path does not exist', () => { beforeEach(async () => { - const plugin = configure({ + const Plugin = configure({ paths: ['foobar'], }) // when - await plugin.onStart(env) + await new Plugin(env).onStart() }) it('does nothing', function () { @@ -78,12 +79,12 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { context('enabled', () => { beforeEach(async () => { - const plugin = configure({ + const Plugin = configure({ paths: [url.fileURLToPath(new URL('resources', import.meta.url))], }) // when - await plugin.onStart(env) + await new Plugin(env).onStart() }) it('deploys trig', async function () { @@ -101,7 +102,7 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { }) context('watch', () => { - let plugin: ReturnType + let plugin: KopflosPlugin let tempDir: string beforeEach(async function () { @@ -118,16 +119,17 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { }) afterEach(async () => { - await plugin?.onStop(env) + await plugin?.onStop?.() await fs.rm(tempDir, { recursive: true, force: true }) }) context('enabled', () => { beforeEach(async () => { - plugin = configure({ + const Plugin = configure({ paths: [tempDir], }) - await plugin.onStart(env) + plugin = new Plugin(env) + await plugin.onStart?.() }) it('redeploys when file changes', async function () { @@ -158,11 +160,12 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { context('disabled', () => { beforeEach(async () => { - plugin = configure({ + const Plugin = configure({ paths: [tempDir], watch: false, }) - await plugin.onStart(env) + plugin = new Plugin(env) + await plugin.onStart?.() }) it('does not react to any changes', async function () { @@ -171,7 +174,7 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { const fileToCreate = path.resolve(tempDir, 'baz.ttl') const fileToDelete = path.resolve(tempDir, 'index.trig') - await plugin.onStart(env) + await plugin.onStart?.() // when await Promise.all([ From 71893f16d163bf76b305f05476cdf2ff2d810d32 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:03:31 +0100 Subject: [PATCH 08/18] chore: update express plugin middlewares --- packages/express/index.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/express/index.ts b/packages/express/index.ts index 211e637..43fc398 100644 --- a/packages/express/index.ts +++ b/packages/express/index.ts @@ -16,14 +16,10 @@ declare module 'express-serve-static-core' { } } -interface MiddlewareHook { - (host: Router, instance: Kopflos): Promise | void -} - declare module '@kopflos-cms/core' { interface KopflosPlugin { - beforeMiddleware?: MiddlewareHook - afterMiddleware?: MiddlewareHook + beforeMiddleware?: (host: Router) => Promise | void + afterMiddleware?: (host: Router) => Promise | void } } @@ -36,7 +32,7 @@ export default async (options: KopflosConfig): Promise<{ middleware: RequestHand const router = Router() - await registerMiddlewares(router, kopflos, kopflos.plugins.map(plugin => plugin.beforeMiddleware)) + await Promise.all(kopflos.plugins.map(plugin => plugin.beforeMiddleware?.(router))) router .use((req, res, next) => { @@ -79,16 +75,10 @@ export default async (options: KopflosConfig): Promise<{ middleware: RequestHand .otherwise((stream) => res.send(stream)) }) - await registerMiddlewares(router, kopflos, kopflos.plugins.map(plugin => plugin.afterMiddleware)) + await Promise.all(kopflos.plugins.map(plugin => plugin.afterMiddleware?.(router))) return { middleware: router, instance: kopflos, } } - -async function registerMiddlewares(router: Router, kopflos: Kopflos, hooks: Array) { - for (const hook of hooks) { - await hook?.(router, kopflos) - } -} From e210c2a5d6758378f9036d690ed8030ed5afb950 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:03:48 +0100 Subject: [PATCH 09/18] ci: add smoke tests --- .github/workflows/tests.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a2f038b..31a706e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,3 +63,22 @@ jobs: node-version: lts/* - run: npm ci - run: npm run -ws --if-present build + + smoke-test-example: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - run: npm ci + - run: docker compose up -d + working-directory: example + - run: npm run build + working-directory: example + - run: npm start & + working-directory: example + - name: Wait for server to start + run: npx wait-on http://localhost:1429 + - run: curl http://localhost:1429/plaque/newton-s-apple-tree-monash-university + - run: curl http://localhost:1429/plaque/newton-s-apple-tree-monash-university.html From 856cf5ebd57729d143118a4dfea3b6b7bcb45b4a Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:08:01 +0100 Subject: [PATCH 10/18] fix: replacing params --- packages/hydra/lib/iriTemplate.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hydra/lib/iriTemplate.ts b/packages/hydra/lib/iriTemplate.ts index 2dd68e0..29fc438 100644 --- a/packages/hydra/lib/iriTemplate.ts +++ b/packages/hydra/lib/iriTemplate.ts @@ -57,10 +57,10 @@ export function fromQuery(rdf: Environment, quer export function applyTemplate(resource: GraphPointer, expanded: string) { let url = new URL(resource.value) - if (expanded.startsWith('?') || expanded.startsWith('$')) { + if (expanded.startsWith('?') || expanded.startsWith('&')) { const searchParams = new URLSearchParams(expanded) for (const [param, value] of searchParams) { - url.searchParams.append(param, value) + url.searchParams.set(param, value) } } else { url = new URL(expanded, url) From de47193ab327dcdfddd38428e1cc29e8293e24fe Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:12:04 +0100 Subject: [PATCH 11/18] ci: request wait timeout --- .github/workflows/tests.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31a706e..5a482a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,6 +79,5 @@ jobs: - run: npm start & working-directory: example - name: Wait for server to start - run: npx wait-on http://localhost:1429 - - run: curl http://localhost:1429/plaque/newton-s-apple-tree-monash-university - - run: curl http://localhost:1429/plaque/newton-s-apple-tree-monash-university.html + run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university -t 30s + - run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university.html -t 1s From ea141e2409389198bae467c620ecd908abfd5761 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:18:03 +0100 Subject: [PATCH 12/18] ci: add logs to artifacts --- .github/workflows/tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5a482a1..51b74e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,8 +76,13 @@ jobs: working-directory: example - run: npm run build working-directory: example - - run: npm start & + - run: npm start > api.log & working-directory: example - name: Wait for server to start run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university -t 30s - run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university.html -t 1s + - uses: actions/upload-artifact@v4 + if: always() + with: + name: api log + path: example/api.log From 337987a28cb8ed1699a4615eb3e0437ec4327f97 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:29:28 +0100 Subject: [PATCH 13/18] ci: more logs to smoke test api --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51b74e3..1d23bdd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -77,6 +77,8 @@ jobs: - run: npm run build working-directory: example - run: npm start > api.log & + env: + log: trace working-directory: example - name: Wait for server to start run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university -t 30s From 5557d478748e03a5f1bd6c52f4e6ebb1a47709f3 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 10:35:54 +0100 Subject: [PATCH 14/18] ci: smoke test prod command --- .github/workflows/tests.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1d23bdd..98a9659 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,9 +74,7 @@ jobs: - run: npm ci - run: docker compose up -d working-directory: example - - run: npm run build - working-directory: example - - run: npm start > api.log & + - run: npm run start:prod > api.log & env: log: trace working-directory: example From 7fbf54bfe60e070c9462dd79824d12993909ce4a Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 12:30:10 +0100 Subject: [PATCH 15/18] ci: smoke test with lando --- .github/workflows/tests.yml | 26 ++++++++++---------------- .lando.yml | 34 ++++++++++++++++++++++++++++++++++ example/docker-compose.yaml | 2 +- example/kopflos.config.ts | 15 +++++++++------ 4 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 .lando.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 98a9659..ac5a210 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,21 +68,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + + - uses: lando/setup-lando@v3 with: - node-version: lts/* - - run: npm ci - - run: docker compose up -d - working-directory: example - - run: npm run start:prod > api.log & - env: - log: trace - working-directory: example - - name: Wait for server to start - run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university -t 30s - - run: npx wait-on http://localhost:1429/plaque/newton-s-apple-tree-monash-university.html -t 1s - - uses: actions/upload-artifact@v4 - if: always() + lando-version: 3.21.2 + - uses: tpluscode/action-lando-start@v0.2.2 with: - name: api log - path: example/api.log + healthcheck: http://db.read-the-plaque.lndo.site/ + timeout: 30000 + ignore-errors: true + + - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university -If + - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university.html -If diff --git a/.lando.yml b/.lando.yml new file mode 100644 index 0000000..dfcc24a --- /dev/null +++ b/.lando.yml @@ -0,0 +1,34 @@ +name: read-the-plaque +services: + oxigraph: + api: 3 + type: lando + entrypoint: /usr/local/bin/oxigraph + app_mount: false + scanner: false + ssl: true + services: + image: ghcr.io/oxigraph/oxigraph:0.4.6 + user: root + command: serve --location /data --bind 0.0.0.0:7878 + ports: + - 7878 + volumes: + - ./example/oxigraph:/data + app: + type: node:20 + ssl: true + scanner: false + build: + - npm i + command: cd example; kopflos serve --mode development --trust-proxy + overrides: + environment: + API_BASE: https://read-the-plaque.lndo.site + DB_URI: http://db.read-the-plaque.lndo.site + +proxy: + oxigraph: + - db.read-the-plaque.lndo.site:7878 + app: + - read-the-plaque.lndo.site:1429 diff --git a/example/docker-compose.yaml b/example/docker-compose.yaml index 69f70c3..f91521c 100644 --- a/example/docker-compose.yaml +++ b/example/docker-compose.yaml @@ -1,6 +1,6 @@ services: oxigraph: - image: ghcr.io/oxigraph/oxigraph:0.4.4 + image: ghcr.io/oxigraph/oxigraph:0.4.6 user: root command: serve --location /data --bind 0.0.0.0:7878 ports: diff --git a/example/kopflos.config.ts b/example/kopflos.config.ts index dd28504..ce73ac7 100644 --- a/example/kopflos.config.ts +++ b/example/kopflos.config.ts @@ -1,14 +1,17 @@ import * as url from 'node:url' import type { KopflosConfig } from '@kopflos-cms/core' +const baseIri = process.env.API_BASE || 'http://localhost:1429' +const dbUri = process.env.DB_URI || 'http://localhost:7878' + export default { - baseIri: 'http://localhost:1429', - apiGraphs: ['http://localhost:1429/api'], + baseIri, + apiGraphs: [baseIri + '/api'], sparql: { default: { - endpointUrl: 'http://localhost:7878/query?union-default-graph', - updateUrl: 'http://localhost:7878/update', - storeUrl: 'http://localhost:7878/store', + endpointUrl: dbUri + '/query?union-default-graph', + updateUrl: dbUri + '/update', + storeUrl: dbUri + '/store', }, }, watch: ['lib'], @@ -28,7 +31,7 @@ export default { entrypoints: ['ui/*.html'], }, '@kopflos-cms/hydra': { - apis: ['http://localhost:1429/api'], + apis: [baseIri + '/api'], }, }, } From 7ed0278c09164ba8bd2cf2ed01eac1577dc6e047 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 12:35:20 +0100 Subject: [PATCH 16/18] ci: ignore lando certificate --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac5a210..29b2073 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -78,5 +78,5 @@ jobs: timeout: 30000 ignore-errors: true - - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university -If - - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university.html -If + - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university -Ifk + - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university.html -Ifk From 769153fb9f47d0584d80ebb663a27c724729070c Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 12:39:54 +0100 Subject: [PATCH 17/18] ci: log on smoke test failure --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29b2073..706db90 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -80,3 +80,6 @@ jobs: - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university -Ifk - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university.html -Ifk + + - run: lando logs + if: failure() From db71d739524691ccff5f20da6ab35d844153d51b Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Wed, 15 Jan 2025 12:43:15 +0100 Subject: [PATCH 18/18] ci: wait longer --- .github/workflows/tests.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 706db90..6f9fcdc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -74,11 +74,10 @@ jobs: lando-version: 3.21.2 - uses: tpluscode/action-lando-start@v0.2.2 with: - healthcheck: http://db.read-the-plaque.lndo.site/ - timeout: 30000 + healthcheck: https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university + timeout: 60000 ignore-errors: true - - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university -Ifk - run: curl https://read-the-plaque.lndo.site/plaque/newton-s-apple-tree-monash-university.html -Ifk - run: lando logs