diff --git a/integration-tests/HydraClient.spec.ts b/integration-tests/HydraClient.spec.ts index fa12eb80..0bcbc2a6 100644 --- a/integration-tests/HydraClient.spec.ts +++ b/integration-tests/HydraClient.spec.ts @@ -35,9 +35,7 @@ describe("Having a Hydra client", () => { }); it("should obtain a collection of events", () => { - const collection = this.entryPoint.hypermedia - .where(item => item.iri.match("/api/events$") && item.type.contains(hydra.Collection)) - .first(); + const collection = this.entryPoint.hypermedia.collections.where(item => item.iri.match("/api/events$")).first(); expect(collection).toBeDefined(); expect(collection).not.toBeNull(); }); @@ -108,8 +106,8 @@ describe("Having a Hydra client", () => { ); it("should obtain matching events", () => { - const matchingEvents = this.searchResult.hypermedia - .where(item => item.iri.match("/api/events$") && item.type.contains(hydra.Collection)) + const matchingEvents = this.searchResult.hypermedia.collections + .where(item => item.iri.match("/api/events$")) .first(); expect(matchingEvents).toBeDefined(); expect(matchingEvents).not.toBeNull(); diff --git a/integration-tests/server/api/context.jsonld b/integration-tests/server/api/context.jsonld index 76c8722f..8182fe78 100644 --- a/integration-tests/server/api/context.jsonld +++ b/integration-tests/server/api/context.jsonld @@ -44,6 +44,10 @@ "@type": "@vocab" }, "required": "hydra:required", + "collection": { + "@id": "hydra:collection", + "@type": "@id" + }, "member": { "@id": "hydra:member", "@type": "@id" diff --git a/src/DataModel/Collections/FilterableCollection.ts b/src/DataModel/Collections/FilterableCollection.ts index 0106d2e5..c01ecf4b 100644 --- a/src/DataModel/Collections/FilterableCollection.ts +++ b/src/DataModel/Collections/FilterableCollection.ts @@ -56,6 +56,19 @@ export default abstract class FilterableCollection { return null; } + /** + * Gets the last item of the collection or null if there are no items matching the criteria. + * @returns {T} + */ + public last(): T { + let result: T = null; + for (const item of this) { + result = item; + } + + return result; + } + /** * Filters the collection with a generic match evaluator. * @param matchEvaluator {Function} Match evaluation delegate. diff --git a/src/DataModel/HypermediaContainer.ts b/src/DataModel/HypermediaContainer.ts index 38b1bb75..289effad 100644 --- a/src/DataModel/HypermediaContainer.ts +++ b/src/DataModel/HypermediaContainer.ts @@ -1,6 +1,9 @@ +import { hydra } from "../namespaces"; import LinksCollection from "./Collections/LinksCollection"; import OperationsCollection from "./Collections/OperationsCollection"; import ResourceFilterableCollection from "./Collections/ResourceFilterableCollection"; +import { ICollection } from "./ICollection"; +import { IHydraResource } from "./IHydraResource"; import { IHypermediaContainer } from "./IHypermediaContainer"; import { IResource } from "./IResource"; @@ -12,6 +15,8 @@ export default class HypermediaContainer extends ResourceFilterableCollection; + public readonly collections: ResourceFilterableCollection; + public readonly operations: OperationsCollection; public readonly links: LinksCollection; @@ -21,7 +26,8 @@ export default class HypermediaContainer extends ResourceFilterableCollection} Hypermedia controls to be stored within this container. * @param operations {OperationsCollection} Operations available on the container. * @param links {LinksCollection} Links available on the container. - * @param members {Iterable} Optional Hydra collection members in case container is a collection. + * @param members {ResourceFilterableCollection} Optional Hydra collection members in case + * container is a collection. */ public constructor( items: Iterable, @@ -30,7 +36,19 @@ export default class HypermediaContainer extends ResourceFilterableCollection ) { super(items); + const itemsArray = Array.from(items); + const explicitelyTypedCollections: ICollection[] = itemsArray + .filter(control => control.type.contains(hydra.Collection)) + .map(control => control as ICollection); + const linkedCollections: ICollection[] = Array.prototype.concat( + ...itemsArray + .filter((control: any) => !!control.collections) + .map((control: IHydraResource) => Array.from(control.collections)) + ); this.operations = operations; + this.collections = new ResourceFilterableCollection( + explicitelyTypedCollections.concat(linkedCollections) + ); this.links = links; this.members = members instanceof ResourceFilterableCollection ? members : new ResourceFilterableCollection(members); diff --git a/src/DataModel/IHydraResource.ts b/src/DataModel/IHydraResource.ts index e1d250d9..a52f2dd2 100644 --- a/src/DataModel/IHydraResource.ts +++ b/src/DataModel/IHydraResource.ts @@ -1,5 +1,7 @@ import LinksCollection from "./Collections/LinksCollection"; import OperationsCollection from "./Collections/OperationsCollection"; +import ResourceFilterableCollection from "./Collections/ResourceFilterableCollection"; +import { ICollection } from "./ICollection"; import { IResource } from "./IResource"; /** @@ -7,6 +9,13 @@ import { IResource } from "./IResource"; * @interface */ export interface IHydraResource extends IResource { + /** + * Gets collections exposed by that resource. + * @readonly + * @returns {ResourceFilterableCollection} + */ + readonly collections: ResourceFilterableCollection; + /** * Gets operations that can be performed on that resource. * @readonly diff --git a/src/DataModel/IHypermediaContainer.ts b/src/DataModel/IHypermediaContainer.ts index 85af689c..5a386f99 100644 --- a/src/DataModel/IHypermediaContainer.ts +++ b/src/DataModel/IHypermediaContainer.ts @@ -1,5 +1,6 @@ import OperationsCollection from "./Collections/OperationsCollection"; import ResourceFilterableCollection from "./Collections/ResourceFilterableCollection"; +import { ICollection } from "./ICollection"; import { IResource } from "./IResource"; /** @@ -21,4 +22,9 @@ export interface IHypermediaContainer extends ResourceFilterableCollection; } diff --git a/src/DataModel/TemplatedResource.ts b/src/DataModel/TemplatedResource.ts index f9dfd046..45064816 100644 --- a/src/DataModel/TemplatedResource.ts +++ b/src/DataModel/TemplatedResource.ts @@ -3,7 +3,9 @@ import { hydra } from "../namespaces"; import LinksCollection from "./Collections/LinksCollection"; import MappingsCollection from "./Collections/MappingsCollection"; import OperationsCollection from "./Collections/OperationsCollection"; +import ResourceFilterableCollection from "./Collections/ResourceFilterableCollection"; import TypesCollection from "./Collections/TypesCollection"; +import { ICollection } from "./ICollection"; import { IIriTemplate } from "./IIriTemplate"; import { IPointingResource } from "./IPointingResource"; import { IResource } from "./IResource"; @@ -27,6 +29,8 @@ export default abstract class TemplatedResource imp public readonly links: LinksCollection; + public readonly collections: ResourceFilterableCollection; + private readonly template: string; private readonly mappings: MappingsCollection; @@ -42,6 +46,7 @@ export default abstract class TemplatedResource imp this.baseUrl = resource.baseUrl; this.iri = resource.iri; this.links = new LinksCollection([]); + this.collections = new ResourceFilterableCollection([]); this.type = new TypesCollection(types.filter((item, index) => types.indexOf(item) === index)); this.target = null; this.template = template.template; @@ -63,6 +68,7 @@ export default abstract class TemplatedResource imp const target = targetUri.match(/^[a-zA-Z][a-zA-Z0-9_]*:/) ? targetUri : new URL(targetUri, this.baseUrl).toString(); return this.createInstance({ baseUrl: this.baseUrl, + collections: this.collections, iri: this.getNextIri(), links: this.links, operations: this.operations, diff --git a/src/JsonLd/JsonLdHypermediaProcessor.ts b/src/JsonLd/JsonLdHypermediaProcessor.ts index a41eb164..d3aea54d 100644 --- a/src/JsonLd/JsonLdHypermediaProcessor.ts +++ b/src/JsonLd/JsonLdHypermediaProcessor.ts @@ -2,6 +2,7 @@ import { promises as jsonLd } from "jsonld"; import ApiDocumentation from "../DataModel/ApiDocumentation"; import LinksCollection from "../DataModel/Collections/LinksCollection"; import OperationsCollection from "../DataModel/Collections/OperationsCollection"; +import ResourceFilterableCollection from "../DataModel/Collections/ResourceFilterableCollection"; import TypesCollection from "../DataModel/Collections/TypesCollection"; import HypermediaContainer from "../DataModel/HypermediaContainer"; import { IApiDocumentation } from "../DataModel/IApiDocumentation"; diff --git a/src/JsonLd/linksExtractor.ts b/src/JsonLd/linksExtractor.ts index 54f20cfe..452d7889 100644 --- a/src/JsonLd/linksExtractor.ts +++ b/src/JsonLd/linksExtractor.ts @@ -1,6 +1,8 @@ import LinksCollection from "../DataModel/Collections/LinksCollection"; import OperationsCollection from "../DataModel/Collections/OperationsCollection"; +import ResourceFilterableCollection from "../DataModel/Collections/ResourceFilterableCollection"; import TypesCollection from "../DataModel/Collections/TypesCollection"; +import { ICollection } from "../DataModel/ICollection"; import TemplatedLink from "../DataModel/TemplatedLink"; import { hydra } from "../namespaces"; @@ -33,6 +35,7 @@ export const linksExtractor = (resources, processingState) => { processingState.forbiddenHypermedia.push(predicate); let link = { baseUrl: processingState.baseUrl, + collections: new ResourceFilterableCollection([]), iri: predicate, links: new LinksCollection([]), operations: new OperationsCollection([]), diff --git a/src/JsonLd/mappings.ts b/src/JsonLd/mappings.ts index 3896ae3a..3bd949f3 100644 --- a/src/JsonLd/mappings.ts +++ b/src/JsonLd/mappings.ts @@ -75,6 +75,11 @@ mappings[hydra.entrypoint] = { required: true, type: [hydra.ApiDocumentation as string] }; +mappings[hydra.collection] = { + default: (collections, processingState) => new ResourceFilterableCollection(collections), + propertyName: "collections", + required: true +}; mappings[hydra.template] = { default: "", propertyName: "template", diff --git a/src/namespaces.ts b/src/namespaces.ts index 8bde39be..b955093a 100644 --- a/src/namespaces.ts +++ b/src/namespaces.ts @@ -22,6 +22,7 @@ export let hydra = { TemplatedLink: hydraNamespace + "TemplatedLink", Link: hydraNamespace + "Link", + collection: hydraNamespace + "collection", member: hydraNamespace + "member", memberTemplate: hydraNamespace + "memberTemplate", totalItems: hydraNamespace + "totalItems", diff --git a/tests/JsonLd/JsonLdMetadataProvider.spec.ts b/tests/JsonLd/JsonLdMetadataProvider.spec.ts index 2bdba642..f4da23f1 100644 --- a/tests/JsonLd/JsonLdMetadataProvider.spec.ts +++ b/tests/JsonLd/JsonLdMetadataProvider.spec.ts @@ -27,7 +27,7 @@ describe("Given instance of the JsonLdHypermediaProcessor class", () => { describe("when parsing", () => { beforeEach( run(async () => { - this.response = returnOk("http://temp.uri/", inputJsonLd); + this.response = returnOk("http://temp.uri/api", inputJsonLd); this.result = await this.hypermediaProcessor.process(this.response, false); }) ); @@ -36,77 +36,105 @@ describe("Given instance of the JsonLdHypermediaProcessor class", () => { expect(this.result).toEqual(inputJsonLd); }); + it("should discover all collections", () => { + expect(this.result.hypermedia.collections.length).toBe(2); + }); + + it("should discover people collection", () => { + expect(this.result.hypermedia.collections.first().iri).toMatch("/api/people$"); + }); + + it("should discover events collection", () => { + expect(this.result.hypermedia.collections.last().iri).toMatch("/api/events$"); + }); + it("should separate hypermedia", () => { expect(this.result.hypermedia).toBeLike([ { - iri: "http://temp.uri/api/events", - links: [ - { - baseUrl: "http://temp.uri/", - iri: "http://temp.uri/vocab/closed-events", - links: [], - operations: [], - relation: "http://temp.uri/vocab/closed-events", - target: { iri: "http://temp.uri/api/events/closed", type: [] }, - type: [hydra.Link] - }, + collections: [ { - baseUrl: "http://temp.uri/", - iri: "http://www.w3.org/ns/hydra/core#first", + collections: [], + iri: "http://temp.uri/api/people", links: [], operations: [], - relation: "http://www.w3.org/ns/hydra/core#first", - target: { iri: "http://temp.uri/api/events?page=1", type: [] }, - type: [hydra.Link] - }, - { - baseUrl: "http://temp.uri/", - iri: "http://www.w3.org/ns/hydra/core#last", - links: [], - operations: [], - relation: "http://www.w3.org/ns/hydra/core#last", - target: { iri: "http://temp.uri/api/events?page=9", type: [] }, - type: [hydra.Link] + type: [] }, { - baseUrl: "http://temp.uri/", - iri: "http://www.w3.org/ns/hydra/core#search", - links: [], - mappings: [ + collections: [], + iri: "http://temp.uri/api/events", + links: [ + { + baseUrl: "http://temp.uri/api", + collections: [], + iri: "http://temp.uri/vocab/closed-events", + links: [], + operations: [], + relation: "http://temp.uri/vocab/closed-events", + target: { iri: "http://temp.uri/api/events/closed", type: [] }, + type: [hydra.Link] + }, { - iri: "_:b1", + baseUrl: "http://temp.uri/api", + collections: [], + iri: "http://www.w3.org/ns/hydra/core#first", links: [], operations: [], - property: { iri: "http://www.w3.org/ns/hydra/core#freetextQuery", type: [] }, - required: false, - type: [], - variable: "searchPhrase" + relation: "http://www.w3.org/ns/hydra/core#first", + target: { iri: "http://temp.uri/api/events?page=1", type: [] }, + type: [hydra.Link] + }, + { + baseUrl: "http://temp.uri/api", + collections: [], + iri: "http://www.w3.org/ns/hydra/core#last", + links: [], + operations: [], + relation: "http://www.w3.org/ns/hydra/core#last", + target: { iri: "http://temp.uri/api/events?page=9", type: [] }, + type: [hydra.Link] + }, + { + baseUrl: "http://temp.uri/api", + collections: [], + iri: "http://www.w3.org/ns/hydra/core#search", + links: [], + mappings: [ + { + collections: [], + iri: "_:b1", + links: [], + operations: [], + property: { iri: "http://www.w3.org/ns/hydra/core#freetextQuery", type: [] }, + required: false, + type: [], + variable: "searchPhrase" + } + ], + operations: [], + relation: "http://www.w3.org/ns/hydra/core#search", + target: null, + template: "http://temp.uri/api/events{?searchPhrase}", + type: [hydra.TemplatedLink] + } + ], + members: [ + { + collections: [], + iri: "http://temp.uri/api/events/1", + links: [], + operations: [], + type: [hydra.Collection] } ], operations: [], - relation: "http://www.w3.org/ns/hydra/core#search", - target: null, - template: "http://temp.uri/api/events{?searchPhrase}", - type: [hydra.TemplatedLink] - } - ], - members: [ - { - iri: "http://temp.uri/api/events/1", - links: [], - operations: [], - type: [] + totalItems: 1, + type: [hydra.Collection] } ], - operations: [], - totalItems: 1, - type: [hydra.Collection] - }, - { - iri: "http://temp.uri/", + iri: "http://temp.uri/api", links: [], operations: [], - type: [] + type: [hydra.EntryPoint] } ]); }); diff --git a/tests/JsonLd/input.json b/tests/JsonLd/input.json index db68448a..6d1b1095 100644 --- a/tests/JsonLd/input.json +++ b/tests/JsonLd/input.json @@ -12,24 +12,35 @@ "@id": "some:named.graph", "@graph": [ { - "@id": "http://temp.uri/api/events", - "@type": "hydra:Collection", - "api:closed-events": "http://temp.uri/api/events/closed", - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "http://temp.uri/api/events{?searchPhrase}", - "hydra:mapping": { - "hydra:variable": "searchPhrase", - "hydra:property": "hydra:freetextQuery", - "hydra:variableRepresentation": "hydra:BasicVariableRepresentation", - "hydra:required": false + "@id": "http://temp.uri/api", + "@type": "hydra:EntryPoint", + "hydra:collection": [ + { + "@id": "http://temp.uri/api/people" + }, + { + "@id": "http://temp.uri/api/events", + "@type": "hydra:Collection", + "api:closed-events": "http://temp.uri/api/events/closed", + "hydra:search": { + "@type": "hydra:IriTemplate", + "hydra:template": "http://temp.uri/api/events{?searchPhrase}", + "hydra:mapping": { + "hydra:variable": "searchPhrase", + "hydra:property": "hydra:freetextQuery", + "hydra:variableRepresentation": "hydra:BasicVariableRepresentation", + "hydra:required": false + } + }, + "hydra:first": "http://temp.uri/api/events?page=1", + "hydra:last": "http://temp.uri/api/events?page=9", + "hydra:totalItems": 1, + "hydra:member": [ + { + "@id": "http://temp.uri/api/events/1" + } + ] } - }, - "hydra:first": "http://temp.uri/api/events?page=1", - "hydra:last": "http://temp.uri/api/events?page=9", - "hydra:totalItems": 1, - "hydra:member": [ - { "@id": "http://temp.uri/api/events/1" } ] }, {