-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
366 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@kopflos-cms/shacl": minor | ||
--- | ||
|
||
SHACL Validation decorator |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import type { KopflosPlugin } from '@kopflos-cms/core' | ||
|
||
type ExtendingTerms = 'shacl#shapeSelector' | ||
|
||
declare module '@kopflos-cms/core/ns.js' { | ||
interface KopflosTerms extends Record<ExtendingTerms, never> { | ||
} | ||
} | ||
|
||
export interface Options { | ||
} | ||
|
||
declare module '@kopflos-cms/core' { | ||
interface PluginConfig { | ||
'@kopflos-cms/shacl'?: Options | ||
} | ||
} | ||
|
||
const decoratorModule = new URL('./lib/decorator.js#decorator', import.meta.url).toString() | ||
|
||
export default function (): KopflosPlugin { | ||
return { | ||
async apiTriples(kopflos) { | ||
const { env } = kopflos | ||
const apis = env.clownface() | ||
.node(kopflos.apis) | ||
|
||
const impl = apis.blankNode() | ||
.addOut(env.ns.rdf.type, env.ns.code.EcmaScriptModule) | ||
.addOut(env.ns.code.link, env.namedNode(decoratorModule)) | ||
|
||
apis | ||
.addOut(env.ns.kl.decorator, decorator => { | ||
decorator.addOut(env.ns.code.implementedBy, impl) | ||
}) | ||
|
||
return apis.dataset | ||
}, | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
import type { Environment } from '@rdfjs/environment/Environment.js' | ||
import type { TraverserFactory } from '@rdfjs/traverser/Factory.js' | ||
import type { MultiPointer } from 'clownface' | ||
import type { DatasetCore, DatasetCoreFactory } from '@rdfjs/types' | ||
|
||
export function clone(ptrs: MultiPointer, env: Environment<TraverserFactory | DatasetCoreFactory>) { | ||
const traverser = env.traverser<DatasetCore>(({ level, quad }) => { | ||
return level === 0 || quad.subject.termType === 'BlankNode' | ||
}) | ||
|
||
return ptrs.toArray().flatMap(ptr => [...traverser.match(ptr)]) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import type { HandlerArgs, KopflosResponse, RequestDecorator, ResultEnvelope } from '@kopflos-cms/core' | ||
import type { ValidationCallback } from './validation.js' | ||
import { shaclValidate } from './validation.js' | ||
import { findShapes } from './shapes.js' | ||
|
||
interface Decorator extends RequestDecorator { | ||
(args: HandlerArgs, next: () => Promise<ResultEnvelope>, validate?: ValidationCallback): Promise<KopflosResponse> | KopflosResponse | ||
} | ||
|
||
export const decorator: Decorator = async (args, next, validate: ValidationCallback = shaclValidate) => { | ||
const validationReport = await validate(args) | ||
|
||
if (!validationReport.conforms) { | ||
return { | ||
status: 400, | ||
body: 'Invalid request', | ||
} | ||
} | ||
|
||
return next() | ||
} | ||
|
||
decorator.applicable = async (args) => { | ||
return args.body.isRDF && (await findShapes(args)).terms.length > 0 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import type { HandlerArgs, KopflosEnvironment } from '@kopflos-cms/core' | ||
import type { MultiPointer } from 'clownface' | ||
import type { DatasetCore } from '@rdfjs/types' | ||
import { isBlankNode, isGraphPointer } from 'is-graph-pointer' | ||
import { clone } from './dataset.js' | ||
|
||
export async function findShapes(args: HandlerArgs): Promise<MultiPointer> { | ||
const { env, resourceShape, method } = args | ||
|
||
const findShapePointer = args.resourceShape | ||
.out(env.ns.kl.handler) | ||
.filter(handler => handler.out(env.ns.kl.method).value?.toUpperCase() === method.toUpperCase()) | ||
.out(env.ns.kl('shacl#shapeSelector')) | ||
.out(env.ns.code.implementedBy) | ||
if (isGraphPointer(findShapePointer)) { | ||
const loaded = await env.load<typeof defaultShapeSelector>(findShapePointer) | ||
if (!loaded) { | ||
throw new Error(`Failed to load shape selector for resource shape ${resourceShape.value}`) | ||
} | ||
|
||
return loaded(args) | ||
} | ||
|
||
return defaultShapeSelector(args) | ||
} | ||
|
||
function defaultShapeSelector({ env, resourceShape, method }: HandlerArgs): MultiPointer { | ||
return resourceShape | ||
.out(env.ns.kl.handler) | ||
.filter(handler => handler.out(env.ns.kl.method).value?.toUpperCase() === method.toUpperCase()) | ||
.filter(handler => handler.out(env.ns.dash.shape).terms.length > 0) | ||
} | ||
|
||
export async function loadShapes(shapes: MultiPointer, env: KopflosEnvironment): Promise<DatasetCore> { | ||
const blankNodeShapes = clone(shapes.filter(isBlankNode), env) | ||
|
||
const dataset = env.dataset(blankNodeShapes) | ||
|
||
// TODO: load named node shapes from graphs | ||
|
||
return dataset | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import type { ValidationReport } from 'rdf-validate-shacl/src/validation-report.js' | ||
import SHACLEngine from 'rdf-validate-shacl' | ||
import type { HandlerArgs } from '@kopflos-cms/core' | ||
import { findShapes, loadShapes } from './shapes.js' | ||
|
||
export interface ValidationCallback { | ||
(args: HandlerArgs): Promise<ValidationReport> | ||
} | ||
|
||
export async function shaclValidate(args: HandlerArgs): Promise<ValidationReport> { | ||
// TODO: do not call findShapes twice | ||
const shapes = await findShapes(args) | ||
const shapesGraph = await loadShapes(shapes, args.env) | ||
const engine = new SHACLEngine(shapesGraph, { factory: args.env }) | ||
|
||
return engine.validate(await args.body.dataset) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
{ | ||
"name": "@kopflos-cms/shacl", | ||
"version": "0.0.0", | ||
"type": "module", | ||
"main": "index.js", | ||
"license": "MIT", | ||
"scripts": { | ||
"test": "mocha", | ||
"build": "tsc", | ||
"prepack": "npm run build" | ||
}, | ||
"files": [ | ||
"CHANGELOG.md", | ||
"*.js", | ||
"*.d.ts" | ||
], | ||
"dependencies": { | ||
"is-graph-pointer": "^2.1.0", | ||
"rdf-validate-shacl": "^0.5.6" | ||
}, | ||
"devDependencies": { | ||
"@kopflos-cms/core": "^0.3.3", | ||
"@types/rdf-validate-shacl": "^0.4.9", | ||
"chai": "^5.1.2", | ||
"mocha-chai-rdf": "^0.1.6", | ||
"sinon": "^18" | ||
}, | ||
"mocha": { | ||
"extension": [ | ||
"ts" | ||
], | ||
"spec": "test/**/*.test.ts", | ||
"loader": "ts-node/esm/transpile-only", | ||
"require": "../../mocha-setup.js" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
import type { HandlerArgs, KopflosEnvironment } from '@kopflos-cms/core' | ||
import { expect } from 'chai' | ||
import { createStore } from 'mocha-chai-rdf/store.js' | ||
import sinon from 'sinon' | ||
// eslint-disable-next-line import/no-unresolved | ||
import { createEnv } from '@kopflos-cms/core/env.js' | ||
import { decorator } from '../../lib/decorator.js' | ||
import { ex } from '../../../testing-helpers/ns.js' | ||
import inMemoryClients from '../../../testing-helpers/in-memory-clients.js' | ||
|
||
describe('@kopflos-cms/shacl/lib/decorator.js', () => { | ||
let env: KopflosEnvironment | ||
|
||
beforeEach(createStore(import.meta.url, { | ||
format: 'trig', | ||
loadAll: true, | ||
})) | ||
|
||
beforeEach(function () { | ||
env = createEnv({ | ||
baseIri: 'http://localhost:1429/', | ||
sparql: { | ||
default: inMemoryClients(this.rdf), | ||
}, | ||
}) | ||
}) | ||
|
||
describe('decorator', () => { | ||
describe('.applicable', () => { | ||
it('returns false when args.body is not RDF', () => { | ||
// given | ||
const req = <HandlerArgs>{ | ||
env, | ||
body: { | ||
isRDF: false, | ||
}, | ||
} | ||
|
||
// when | ||
const result = decorator.applicable!(req) | ||
|
||
// then | ||
expect(result).to.be.false | ||
}) | ||
|
||
it('returns false when handler has no dash:shape', function () { | ||
// given | ||
const req = <HandlerArgs>{ | ||
env, | ||
resourceShape: this.rdf.graph.node(ex.noValidation), | ||
method: 'PUT', | ||
body: { | ||
isRDF: true, | ||
}, | ||
} | ||
|
||
// when | ||
const result = decorator.applicable!(req) | ||
|
||
// then | ||
expect(result).to.be.false | ||
}) | ||
|
||
it('returns true when handler has a single dash:shape', function () { | ||
// given | ||
const req = <HandlerArgs>{ | ||
env, | ||
resourceShape: this.rdf.graph.node(ex.oneShape), | ||
method: 'PUT', | ||
body: { | ||
isRDF: true, | ||
}, | ||
} | ||
|
||
// when | ||
const result = decorator.applicable!(req) | ||
|
||
// then | ||
expect(result).to.be.true | ||
}) | ||
|
||
it('returns true when handler has a multiple dash:shape', function () { | ||
// given | ||
const req = <HandlerArgs>{ | ||
env, | ||
resourceShape: this.rdf.graph.node(ex.twoShapes), | ||
method: 'PUT', | ||
body: { | ||
isRDF: true, | ||
}, | ||
} | ||
|
||
// when | ||
const result = decorator.applicable!(req) | ||
|
||
// then | ||
expect(result).to.be.true | ||
}) | ||
}) | ||
|
||
describe('when called', () => { | ||
it('calls next when validation passes', async function () { | ||
// given | ||
const req = <HandlerArgs>{ | ||
env, | ||
resourceShape: this.rdf.graph.node(ex.oneShape), | ||
method: 'PUT', | ||
body: { | ||
isRDF: true, | ||
}, | ||
} | ||
const mockValidation = sinon.stub() | ||
.resolves({ | ||
conforms: true, | ||
}) | ||
|
||
// when | ||
const result = await decorator(req, () => Promise.resolve({ status: 200, body: 'OK' }), mockValidation) | ||
|
||
// then | ||
expect(result).to.deep.equal({ status: 200, body: 'OK' }) | ||
}) | ||
|
||
it('returns Bad Request when validation fails', async function () { | ||
// given | ||
const req = <HandlerArgs>{ | ||
env, | ||
resourceShape: this.rdf.graph.node(ex.oneShape), | ||
method: 'PUT', | ||
body: { | ||
isRDF: true, | ||
}, | ||
} | ||
const mockValidation = sinon.stub() | ||
.resolves({ | ||
conforms: false, | ||
}) | ||
|
||
// when | ||
const result = await decorator(req, () => Promise.resolve({ status: 200, body: 'OK' }), mockValidation) | ||
|
||
// then | ||
expect(result).to.have.property('status', 400) | ||
}) | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> | ||
PREFIX schema: <http://schema.org/> | ||
PREFIX sh: <http://www.w3.org/ns/shacl#> | ||
PREFIX dash: <http://datashapes.org/dash#> | ||
PREFIX kl: <https://kopflos.described.at/> | ||
PREFIX ex: <http://example.org/> | ||
|
||
ex:noValidation | ||
a kl:ResourceShape ; | ||
kl:handler | ||
[ | ||
a kl:Handler ; | ||
kl:method "PUT" ; | ||
] ; | ||
. | ||
|
||
ex:oneShape | ||
a kl:ResourceShape ; | ||
kl:handler | ||
[ | ||
a kl:Handler ; | ||
kl:method "PUT" ; | ||
dash:shape ex:Shape ; | ||
] ; | ||
. | ||
|
||
ex:twoShapes | ||
a kl:ResourceShape ; | ||
kl:handler | ||
[ | ||
a kl:Handler ; | ||
kl:method "PUT" ; | ||
dash:shape | ||
ex:Shape, | ||
[ | ||
a sh:NodeShape ; | ||
] ; | ||
] ; | ||
. |