Skip to content

Commit

Permalink
feat: SHACL validation
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Jan 31, 2025
1 parent 608724b commit bd6aa25
Show file tree
Hide file tree
Showing 10 changed files with 366 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/chilly-peas-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kopflos-cms/shacl": minor
---

SHACL Validation decorator
5 changes: 3 additions & 2 deletions packages/core/lib/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { HandlerArgs } from './handler.js'
import log from './log.js'

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

Expand All @@ -24,7 +24,8 @@ export const loadDecorators = async ({ env, apis }: Pick<Kopflos<DatasetCore>, '
if (!impl) {
log.warn('Decorator has no implementation')
}
if (!impl?.applicable || impl.applicable(args)) {

if (!impl?.applicable || await impl.applicable(args)) {
return impl
}
}))
Expand Down
40 changes: 40 additions & 0 deletions packages/shacl/index.ts
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
},
}
}
12 changes: 12 additions & 0 deletions packages/shacl/lib/dataset.ts
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)])
}
25 changes: 25 additions & 0 deletions packages/shacl/lib/decorator.ts
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
}
42 changes: 42 additions & 0 deletions packages/shacl/lib/shapes.ts
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
}
17 changes: 17 additions & 0 deletions packages/shacl/lib/validation.ts
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)
}
36 changes: 36 additions & 0 deletions packages/shacl/package.json
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"
}
}
147 changes: 147 additions & 0 deletions packages/shacl/test/lib/decorator.test.ts
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)
})
})
})
})
39 changes: 39 additions & 0 deletions packages/shacl/test/lib/decorator.test.ts.trig
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 ;
] ;
] ;
.

0 comments on commit bd6aa25

Please sign in to comment.