From f88a5e799dcb5b084b7cd311d5e9ebb56abd0cf5 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Mon, 30 Mar 2026 14:45:10 +0200 Subject: [PATCH] feat: add support for hydra:manages --- src/core/Resource.ts | 7 + src/hydra/parseHydraDocumentation.test.ts | 169 ++++++++++++++++++++++ src/hydra/parseHydraDocumentation.ts | 37 ++++- src/hydra/types.ts | 14 ++ 4 files changed, 226 insertions(+), 1 deletion(-) diff --git a/src/core/Resource.ts b/src/core/Resource.ts index 65198cd..c741f42 100644 --- a/src/core/Resource.ts +++ b/src/core/Resource.ts @@ -3,6 +3,11 @@ import type { Operation } from "./Operation.js"; import type { Parameter } from "./Parameter.js"; import type { Nullable } from "./types.js"; +export interface ManagesBlock { + property?: string; + object?: string; +} + export interface ResourceOptions extends Nullable<{ id?: string; @@ -15,6 +20,7 @@ export interface ResourceOptions operations?: Operation[]; deprecated?: boolean; parameters?: Parameter[]; + manages?: ManagesBlock[]; }> {} export class Resource implements ResourceOptions { @@ -31,6 +37,7 @@ export class Resource implements ResourceOptions { operations?: Operation[] | null; deprecated?: boolean | null; parameters?: Parameter[] | null; + manages?: ManagesBlock[] | null; constructor(name: string, url: string, options: ResourceOptions = {}) { this.name = name; diff --git a/src/hydra/parseHydraDocumentation.test.ts b/src/hydra/parseHydraDocumentation.test.ts index 1bc6ebd..1a2ec36 100644 --- a/src/hydra/parseHydraDocumentation.test.ts +++ b/src/hydra/parseHydraDocumentation.test.ts @@ -2583,3 +2583,172 @@ test("parse a Hydra documentation with bare Link @type (without hydra prefix)", expect(reviewField.reference).toBe(reviewResource); expect(reviewField.embedded).toBeNull(); }); + +test("parse a Hydra documentation with hydra:manages", async () => { + const managesEntrypoint = { + "@context": { + "@vocab": "http://localhost/docs.jsonld#", + hydra: "http://www.w3.org/ns/hydra/core#", + comment: { + "@id": "Entrypoint/comment", + "@type": "@id", + }, + }, + "@id": "/", + "@type": "Entrypoint", + comment: "/comments", + }; + + const managesDocs = { + "@context": { + "@vocab": "http://localhost/docs.jsonld#", + hydra: "http://www.w3.org/ns/hydra/core#", + rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + rdfs: "http://www.w3.org/2000/01/rdf-schema#", + xmls: "http://www.w3.org/2001/XMLSchema#", + owl: "http://www.w3.org/2002/07/owl#", + domain: { + "@id": "rdfs:domain", + "@type": "@id", + }, + range: { + "@id": "rdfs:range", + "@type": "@id", + }, + expects: { + "@id": "hydra:expects", + "@type": "@id", + }, + returns: { + "@id": "hydra:returns", + "@type": "@id", + }, + }, + "@id": "/docs.jsonld", + "hydra:title": "API with manages", + "hydra:description": "A test", + "hydra:entrypoint": "/", + "hydra:supportedClass": [ + { + "@id": "http://schema.org/Comment", + "@type": "hydra:Class", + "rdfs:label": "Comment", + "hydra:title": "Comment", + "hydra:supportedProperty": [ + { + "@type": "hydra:SupportedProperty", + "hydra:property": { + "@id": "http://schema.org/text", + "@type": "rdf:Property", + "rdfs:label": "text", + domain: "http://schema.org/Comment", + range: "xmls:string", + }, + "hydra:title": "text", + "hydra:required": true, + "hydra:readable": true, + "hydra:writeable": true, + }, + { + "@type": "hydra:SupportedProperty", + "hydra:property": { + "@id": "http://schema.org/about", + "@type": "hydra:Link", + "rdfs:label": "about", + domain: "http://schema.org/Comment", + range: "http://schema.org/Thing", + }, + "hydra:title": "about", + "hydra:required": true, + "hydra:readable": true, + "hydra:writeable": true, + }, + ], + "hydra:supportedOperation": [ + { + "@type": "hydra:Operation", + "hydra:method": "GET", + "hydra:title": "Retrieves Comment resource.", + "rdfs:label": "Retrieves Comment resource.", + returns: "http://schema.org/Comment", + }, + ], + }, + { + "@id": "#Entrypoint", + "@type": "hydra:Class", + "hydra:title": "The API entrypoint", + "hydra:supportedProperty": [ + { + "@type": "hydra:SupportedProperty", + "hydra:property": { + "@id": "#Entrypoint/comment", + "@type": "hydra:Link", + domain: "#Entrypoint", + "rdfs:label": "The collection of Comment resources", + "rdfs:range": [ + { "@id": "hydra:Collection" }, + { + "owl:equivalentClass": { + "owl:onProperty": { "@id": "hydra:member" }, + "owl:allValuesFrom": { "@id": "http://schema.org/Comment" }, + }, + }, + ], + "hydra:manages": [ + { + "hydra:property": { "@id": "http://example.com/vocab#comment" }, + "hydra:object": { "@id": "http://schema.org/Comment" }, + }, + ], + }, + "hydra:title": "The collection of Comment resources", + "hydra:readable": true, + "hydra:writeable": false, + "hydra:supportedOperation": [ + { + "@type": "hydra:Operation", + "hydra:method": "GET", + "hydra:title": "Retrieves the collection of Comment resources.", + "rdfs:label": "Retrieves the collection of Comment resources.", + returns: "hydra:Collection", + }, + ], + }, + ], + }, + ], + }; + + const init = { headers: { "Content-Type": "application/ld+json" } }; + server.use( + http.get("http://localhost", () => + Response.json(managesEntrypoint, { + headers: { + ...init.headers, + Link: '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', + }, + }), + ), + http.get("http://localhost/docs.jsonld", () => + Response.json(managesDocs, init), + ), + ); + + const data = await parseHydraDocumentation("http://localhost"); + expect(data.status).toBe(200); + + const commentResource = data.api.resources?.find( + (r) => r.id === "http://schema.org/Comment", + ); + expect(commentResource).toBeDefined(); + assert(commentResource !== undefined); + + expect(commentResource.manages).toBeDefined(); + expect(commentResource.manages).toHaveLength(1); + expect(commentResource.manages?.[0]).toEqual({ + property: "http://example.com/vocab#comment", + object: "http://schema.org/Comment", + }); +}); + diff --git a/src/hydra/parseHydraDocumentation.ts b/src/hydra/parseHydraDocumentation.ts index d2fda0a..1f96c7a 100644 --- a/src/hydra/parseHydraDocumentation.ts +++ b/src/hydra/parseHydraDocumentation.ts @@ -1,6 +1,7 @@ import jsonld from "jsonld"; import type { OperationType, Parameter } from "../core/index.js"; import { Api, Field, Operation, Resource } from "../core/index.js"; +import type { ManagesBlock } from "../core/Resource.js"; import type { RequestInitExtended } from "../core/types.js"; import { removeTrailingSlash } from "../core/utils/index.js"; import fetchJsonLd from "./fetchJsonLd.js"; @@ -183,7 +184,8 @@ function findRelatedClass( docs: ExpandedDoc[], property: ExpandedRdfProperty, ): ExpandedClass { - // Use the entrypoint property's owl:equivalentClass if available + // Try to use hydra:manages if available (new approach) + // Otherwise fall back to owl:equivalentClass (legacy approach) for (const range of property["http://www.w3.org/2000/01/rdf-schema#range"] ?? []) { @@ -289,6 +291,36 @@ function findRelatedClass( throw new Error(`Cannot find the class related to ${property["@id"]}.`); } +/** + * Extracts manages blocks from a property. + * A manages block describes the relations between collection members and other resources. + * @param {ExpandedRdfProperty} property The property containing manages blocks. + * @returns {ManagesBlock[]} Array of manages blocks. + */ +function getManagesBlocks(property: ExpandedRdfProperty): ManagesBlock[] { + const manages = property["http://www.w3.org/ns/hydra/core#manages"]; + + if (!manages || !Array.isArray(manages)) { + return []; + } + + return manages + .map((manage) => { + const prop = manage["http://www.w3.org/ns/hydra/core#property"]?.[0]?.["@id"]; + const object = manage["http://www.w3.org/ns/hydra/core#object"]?.[0]?.["@id"]; + + if (!prop && !object) { + return null; + } + + return { + ...(prop && { property: prop }), + ...(object && { object }), + } as ManagesBlock; + }) + .filter((block): block is ManagesBlock => block !== null); +} + /** * Parses Hydra documentation and converts it to an intermediate representation. * @param {string} entrypointUrl The API entrypoint URL. @@ -530,6 +562,8 @@ export default async function parseHydraDocumentation( operations.push(operation); } + const manages = getManagesBlocks(property); + const resource = new Resource(guessNameFromUrl(url, entrypointUrl), url, { id: relatedClass["@id"], title: @@ -544,6 +578,7 @@ export default async function parseHydraDocumentation( relatedClass?.["http://www.w3.org/2002/07/owl#deprecated"]?.[0]?.[ "@value" ] ?? false, + ...(manages.length > 0 && { manages }), }); resource.parameters = []; diff --git a/src/hydra/types.ts b/src/hydra/types.ts index 44dcc38..73fffd2 100644 --- a/src/hydra/types.ts +++ b/src/hydra/types.ts @@ -5,6 +5,19 @@ export interface IriTemplateMapping { required: boolean; } +export interface ExpandedManages { + "http://www.w3.org/ns/hydra/core#property"?: [ + { + "@id": string; + }, + ]; + "http://www.w3.org/ns/hydra/core#object"?: [ + { + "@id": string; + }, + ]; +} + export interface ExpandedOperation { "@type": ["http://www.w3.org/ns/hydra/core#Operation"]; "http://www.w3.org/2000/01/rdf-schema#label": [ @@ -88,6 +101,7 @@ export interface ExpandedRdfProperty { "@value": number; }, ]; + "http://www.w3.org/ns/hydra/core#manages"?: ExpandedManages[]; } interface ExpandedSupportedProperty {