Skip to content

Commit

Permalink
Extend hypermedia interface to support collections
Browse files Browse the repository at this point in the history
This addresses use case 3 (retrieving events).

* Initial commit.
* Changes after PR feerback.
* Added two first use cases in form of a full web stack integration test.
* Regenerated docs.
* Changes after code review/
* Minor changes after latest review.
* Minor changes after latest review.
* Few more minor changes before merging.
* Fixed test setup for travis
* Changes for resource processing that doesn't remove hypermedia
* Added use-case 3 integration test and required implementation changes.
* Code review feedback changes
* Quick code review change
* Minor feedback fix
  • Loading branch information
alien-mcl authored and lanthaler committed Sep 18, 2017
1 parent b09e1b5 commit abb69dd
Show file tree
Hide file tree
Showing 13 changed files with 235 additions and 33 deletions.
12 changes: 12 additions & 0 deletions integration-tests/HydraClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import HydraClient from "../src/HydraClient";
import {run} from "../testing/AsyncHelper";
import {hydra} from "../src/namespaces";

describe("Having a Hydra client", function() {
beforeEach(function() {
Expand Down Expand Up @@ -29,6 +30,17 @@ describe("Having a Hydra client", function() {
expect(this.entryPoint.hypermedia.find(item => item.iri.match("\/api\/events$") && item.isA === "Colletion"))
.not.toBeNull();
});

describe("and then obtaining events as in use case 3.obtaining-events", function() {
beforeEach(run(async function () {
this.events = await this.client.getResource(this.url + "api/events");
this.members = this.events.hypermedia.members;
}));

it("should obtain a collection of events", function () {
expect(this.members.filter(member => member.isA.indexOf("http://schema.org/Event") !== -1).length).toBe(3);
});
});
});

describe("and obtaining it's API documentation as in use case 2.api-documentation", function() {
Expand Down
19 changes: 19 additions & 0 deletions integration-tests/server/api/events.jsonld
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"@context": "/api/context.jsonld",
"@id": "/api/events",
"@type": "hydra:Collection",
"member": [
{
"@id": "/api/events/1",
"@type": "schema:Event"
},
{
"@id": "/api/events/2",
"@type": "schema:Event"
},
{
"@id": "/api/events/3",
"@type": "schema:Event"
}
]
}
3 changes: 2 additions & 1 deletion src/DataModel/IHydraResource.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {IOperation} from "./IOperation";
import {IResource} from "./IResource";
import {IHypermedia} from "./IHypermedia";
/**
* @interface Describes an abstract Hydra resource.
*/
export interface IHydraResource extends IResource
export interface IHydraResource extends IResource, IHypermedia
{
/**
* @readonly Gets classes a given resource is of.
Expand Down
12 changes: 12 additions & 0 deletions src/DataModel/IHypermediaContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {IHypermedia} from "./IHypermedia";
import {IResource} from "./IResource";
/**
* @interface Provides an abstraction layer over hypermedia container.
*/
export interface IHypermediaContainer extends Array<IHypermedia>
{
/**
* @readonly Gets a collection members. This may be null if the resource owning this container is not a hydra:Collection.
*/
readonly members: Array<IResource>;
}
4 changes: 2 additions & 2 deletions src/DataModel/IWebResource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {IHypermedia} from "./IHypermedia";
import {IHypermediaContainer} from "./IHypermediaContainer";

/**
* @interface Describes an abstract web resource.
Expand All @@ -8,5 +8,5 @@ export interface IWebResource extends Object
/**
* @readonly Gets a collection of hypermedia controls.
*/
readonly hypermedia: Array<IHypermedia>;
readonly hypermedia: IHypermediaContainer;
}
44 changes: 38 additions & 6 deletions src/DataModel/JsonLd/JsonLdHypermediaProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default class JsonLdHypermediaProcessor implements IHypermediaProcessor
if (!removeFromPayload)
{
hypermedia = await jsonLd.frame(payload, context, { embed: "@link" });
hypermedia = hypermedia["@graph"];
hypermedia = JsonLdHypermediaProcessor.fixType(hypermedia["@graph"]);
}
else
{
Expand All @@ -46,10 +46,16 @@ export default class JsonLdHypermediaProcessor implements IHypermediaProcessor
{
for (let index = result.length - 1; index >= 0; index--)
{
if ((Object.keys(result[index]).length == 1) && (result[index].iri))
let keys = ["iri", "isA"].concat(Object.keys(result[index]));
keys = keys.filter((key, index) => keys.indexOf(key) === index);
if (keys.length == 2)
{
result.splice(index, 1);
}
else
{
JsonLdHypermediaProcessor.fixTypeOf(result[index]);
}
}

return result;
Expand Down Expand Up @@ -102,15 +108,19 @@ export default class JsonLdHypermediaProcessor implements IHypermediaProcessor
private static processResource(resource: any, result: Array<any> & { [key: string]: any }, removeFromPayload: boolean): any
{
let targetResource;
if ((resource["@id"]) && (targetResource = result[resource["@id"]]))
if (resource["@id"])
{
targetResource["@id"] = resource["@id"];
targetResource = result[resource["@id"]];
}

if (!targetResource)
{
targetResource = {};
Object.defineProperty(result, JsonLdHypermediaProcessor.generateBlankNodeId(), { enumerable: false, value: targetResource });
targetResource =
{
"@id": resource["@id"] || JsonLdHypermediaProcessor.generateBlankNodeId(),
"@type": resource["@type"] || new Array<string>()
};
Object.defineProperty(result, targetResource["@id"], { enumerable: false, value: targetResource });
result.push(targetResource);
}

Expand All @@ -128,6 +138,28 @@ export default class JsonLdHypermediaProcessor implements IHypermediaProcessor

return result;
}

private static fixType(result: Array<any> & { [key: string]: any })
{
for (let resource of result)
{
JsonLdHypermediaProcessor.fixTypeOf(resource);
}

return result;
}

private static fixTypeOf(resource: any)
{
if (!resource.isA)
{
resource.isA = [];
}
else if (!(resource.isA instanceof Array))
{
resource.isA = [resource.isA];
}
}
}

JsonLdHypermediaProcessor.initialize();
4 changes: 3 additions & 1 deletion src/DataModel/JsonLd/context.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"members": {
"@id": "hydra:member",
"@container": "@set"
}
},
"http://www.w3.org/ns/hydra/core#Collection": "http://www.w3.org/ns/hydra/core#Collection",
"http://www.w3.org/ns/hydra/core#Resource": "http://www.w3.org/ns/hydra/core#Resource"
}
}
21 changes: 19 additions & 2 deletions src/HydraClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import {hydra} from "./namespaces";
import {IHypermediaProcessor} from "./DataModel/IHypermediaProcessor";
import {IApiDocumentation} from "./DataModel/IApiDocumentation";
import {IWebResource} from "./DataModel/IWebResource";
import ApiDocumentation from "./ApiDocumentation";
import {IResource} from "./DataModel/IResource";
import ApiDocumentation from "./ApiDocumentation";
import ResourceEnrichmentProvider from "./ResourceEnrichmentProvider";
const jsonld = require("jsonld");
require("isomorphic-fetch");

Expand All @@ -14,6 +15,8 @@ require("isomorphic-fetch");
export default class HydraClient
{
private static _hypermediaProcessors = new Array<IHypermediaProcessor>();
private static _resourceEnrichmentProvider: { enrichHypermedia(resource: IWebResource): IWebResource } =
new ResourceEnrichmentProvider();
private _removeHypermediaFromPayload;

public static noUrlProvided = "There was no Url provided.";
Expand All @@ -34,6 +37,19 @@ export default class HydraClient
this._removeHypermediaFromPayload = removeHypermediaFromPayload;
}

/**
* Registers a custom resource enrichment provider.
* @param resourceEnrichmentProvider Component to be registered.
*/
public static registerResourceEnrichmentProvider(
resourceEnrichmentProvider: { enrichHypermedia(resource: IWebResource): IWebResource })
{
if (resourceEnrichmentProvider)
{
HydraClient._resourceEnrichmentProvider = resourceEnrichmentProvider;
}
}

/**
* Registers a hypermedia processor.
* @param {IHypermediaProcessor} hypermediaProcessor Hypermedia processor to be registered.
Expand Down Expand Up @@ -100,7 +116,8 @@ export default class HydraClient
throw new Error(HydraClient.responseFormatNotSupported);
}

return await hypermediaProcessor.process(response, this._removeHypermediaFromPayload);
let result = await hypermediaProcessor.process(response, this._removeHypermediaFromPayload);
return HydraClient._resourceEnrichmentProvider.enrichHypermedia(result);
}

private async getApiDocumentationUrl(url: string): Promise<string>
Expand Down
55 changes: 55 additions & 0 deletions src/ResourceEnrichmentProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {IWebResource} from "./DataModel/IWebResource";
import {IHypermedia} from "./DataModel/IHypermedia";
import {IHydraResource} from "./DataModel/IHydraResource";
import {hydra} from "./namespaces";
import {IResource} from "./DataModel/IResource";

/**
* @class @name ResourceEnrichmentProvider
* Provides IWebResource enrichment routines.
*/
export default class ResourceEnrichmentProvider
{
private static properties =
{
members: { type: hydra.Collection, propertyName: "members" }
};

/**
* Enriches a given resource with IHypermediaContainer specific properties.
* @param resource Resource to be enriched.
* @returns {IWebResource}
*/
public enrichHypermedia(resource: IWebResource): IWebResource
{
if (!resource)
{
return resource;
}

if (!resource.hypermedia)
{
Object.defineProperty(resource, "hypermedia", { value: new Array<IHypermedia>(), enumerable: false });
}

for (let propertyName of Object.keys(ResourceEnrichmentProvider.properties))
{
let propertyDefinition = ResourceEnrichmentProvider.properties[propertyName];
let value = null;
let collections = resource.hypermedia
.filter(item =>
(<IHydraResource>item).isA &&
(<IHydraResource>item).isA.find(type => type === propertyDefinition.type));
if (collections.length > 0)
{
value = Array.prototype.concat.apply(
new Array<IResource>(),
collections.map(collection => (<any>collection)[propertyDefinition.propertyName]));
}

Object.defineProperty(resource.hypermedia, propertyName, { value: value, enumerable: false });
}

return resource;
}
}
5 changes: 4 additions & 1 deletion src/namespaces.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export let hydra: any = new String("http://www.w3.org/ns/hydra/core#");
hydra.namespace = hydra.toString();
hydra.apiDocumentation = hydra + "apiDocumentation";
hydra.member = hydra + "member";
hydra.ApiDocumentation = hydra + "ApiDocumentation";
hydra.EntryPoint = hydra + "EntryPoint";
hydra.EntryPoint = hydra + "EntryPoint";
hydra.Collection = hydra + "Collection";
hydra.Resource = hydra + "Resource";
26 changes: 15 additions & 11 deletions tests/DataModel/JsonLd/JsonLdMetadataProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import HydraClient from "../../../src/HydraClient";
import {returnOk} from "../../../testing/ResponseHelper";
import JsonLdHypermediaProcessor from "../../../src/DataModel/JsonLd/JsonLdHypermediaProcessor";
import {run} from "../../../testing/AsyncHelper";
import {hydra} from "../../../src/namespaces";
const inputJsonLd = require("./input.json");

describe("Given instance of the JsonLdHypermediaProcessor class", function() {
Expand All @@ -26,23 +27,24 @@ describe("Given instance of the JsonLdHypermediaProcessor class", function() {
});

describe("without removing hypermedia controls", function() {
it("should process data", run(async function() {
let result = await this.hypermediaProcessor.process(this.response, false);

expect(result).toEqual(inputJsonLd);
beforeEach(run(async function() {
this.result = await this.hypermediaProcessor.process(this.response, false);
}));

it("should separate hypermedia", run(async function() {
let result = await this.hypermediaProcessor.process(this.response, false);
it("should process data", function() {
expect(this.result).toEqual(inputJsonLd);
});

expect(result.hypermedia).toEqual([
it("should separate hypermedia", function() {
expect(this.result.hypermedia).toEqual([
{
iri: "http://temp.uri/api/events",
isA: "Collection",
isA: [hydra.Collection],
totalItems: 1,
members: [
{
iri: "http://temp.uri/api/events/1",
isA: [],
"http://schema.org/endDate": "2017-04-19",
"http://schema.org/eventDescription": "Some event 1",
"http://schema.org/eventName": "Event 1",
Expand All @@ -51,15 +53,17 @@ describe("Given instance of the JsonLdHypermediaProcessor class", function() {
]
}, {
iri: "http://temp.uri/api/events/1",
isA: [],
"http://schema.org/endDate": "2017-04-19",
"http://schema.org/eventDescription": "Some event 1",
"http://schema.org/eventName": "Event 1",
"http://schema.org/startDate": "2017-04-19"
}, {
iri: 'some:named.graph'
iri: 'some:named.graph',
isA: []
}
]);
}));
});
});

describe("and removing hypermedia controls", function() {
Expand Down Expand Up @@ -88,7 +92,7 @@ describe("Given instance of the JsonLdHypermediaProcessor class", function() {
expect(result.hypermedia).toEqual([
{
"iri": "http://temp.uri/api/events",
"isA": "Collection",
"isA": [hydra.Collection],
"totalItems": 1,
"members": [
{ "iri": "http://temp.uri/api/events/1" }
Expand Down
Loading

0 comments on commit abb69dd

Please sign in to comment.