diff --git a/package.json b/package.json index 8690176..b0ca4d3 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "eslint-plugin-import": "^2.2.0", "eslint-plugin-prettier": "^2.6.0", "flow-bin": "^0.42.0", - "jest": "^20.0.0", + "jest": "^23.0.0", "jest-fetch-mock": "^1.0.8", "prettier": "^1.12.1" }, diff --git a/src/Parameter.js b/src/Parameter.js new file mode 100644 index 0000000..9e2dc2d --- /dev/null +++ b/src/Parameter.js @@ -0,0 +1,29 @@ +// @flow + +/** + * @property {string} variable - The variable of this field + */ +export default class Parameter { + variable: string; + range: string; + required: boolean; + description: string; + + /** + * @param {string} variable + * @param {string} range + * @param {boolean} required + * @param {string} description + */ + constructor( + variable: string, + range: string, + required: boolean, + description: string + ) { + this.variable = variable; + this.range = range; + this.required = required; + this.description = description; + } +} diff --git a/src/Resource.js b/src/Resource.js index 868c094..a4b14e9 100644 --- a/src/Resource.js +++ b/src/Resource.js @@ -2,6 +2,7 @@ import Field from "./Field"; import Operation from "./Operation"; +import Parameter from "./Parameter"; type ResourceOptions = { id?: string, @@ -9,6 +10,7 @@ type ResourceOptions = { deprecated?: boolean, readableFields?: Field[], writableFields?: Field[], + parameters?: Parameter[], operations?: Operation[] }; diff --git a/src/hydra/addParameters.js b/src/hydra/addParameters.js new file mode 100644 index 0000000..32f0291 --- /dev/null +++ b/src/hydra/addParameters.js @@ -0,0 +1,34 @@ +import Parameter from "../Parameter"; +import fetchResource from "./fetchResource"; + +export default api => { + const promises = []; + + for (const resource of api.resources) { + const promise = fetchResource(resource.url).then(({ parameters = {} }) => { + const resourceParameters = []; + parameters.forEach(({ property = null, required, variable }) => { + if (null === property) { + return; + } + + const { range = null } = + resource.fields.find(({ name }) => property === name) || {}; + + resourceParameters.push(new Parameter(variable, range, required, "")); + }); + + return resourceParameters; + }); + + promises.push(promise); + } + + return Promise.all(promises).then(values => { + api.resources.map((resource, index) => { + resource.parameters = values[index]; + }); + + return api; + }); +}; diff --git a/src/hydra/fetchResource.js b/src/hydra/fetchResource.js new file mode 100644 index 0000000..7a12e67 --- /dev/null +++ b/src/hydra/fetchResource.js @@ -0,0 +1,13 @@ +import fetchJsonLd from "./fetchJsonLd"; +import get from "lodash.get"; + +export default async resourceUrl => { + return await fetchJsonLd(resourceUrl, { itemsPerPage: 0 }).then( + d => ({ + parameters: get(d, "body.hydra:search.hydra:mapping") + }), + () => { + throw new Error("Unreachable resource"); + } + ); +}; diff --git a/src/hydra/parseHydraDocumentation.js b/src/hydra/parseHydraDocumentation.js index dbd2ea3..e04d502 100644 --- a/src/hydra/parseHydraDocumentation.js +++ b/src/hydra/parseHydraDocumentation.js @@ -5,6 +5,7 @@ import Field from "../Field"; import Resource from "../Resource"; import Operation from "../Operation"; import fetchJsonLd from "./fetchJsonLd"; +import addParameters from "./addParameters"; /** * Extracts the short name of a resource. @@ -190,157 +191,203 @@ function findRelatedClass(docs, property) { export default function parseHydraDocumentation(entrypointUrl, options = {}) { entrypointUrl = removeTrailingSlash(entrypointUrl); - return fetchEntrypointAndDocs(entrypointUrl, options).then( - ({ entrypoint, docs, response }) => { - const resources = [], - fields = [], - operations = []; - const title = get( - docs, - '[0]["http://www.w3.org/ns/hydra/core#title"][0]["@value"]', - "API Platform" - ); - - const entrypointType = get(entrypoint, '[0]["@type"][0]'); - if (!entrypointType) { - throw new Error('The API entrypoint has no "@type" key.'); - } - - const entrypointClass = findSupportedClass(docs, entrypointType); - if ( - !Array.isArray( - entrypointClass["http://www.w3.org/ns/hydra/core#supportedProperty"] - ) - ) { - throw new Error( - 'The entrypoint definition has no "http://www.w3.org/ns/hydra/core#supportedProperty" key or it is not an array.' + return fetchEntrypointAndDocs(entrypointUrl, options) + .then( + ({ entrypoint, docs, response }) => { + const resources = [], + fields = [], + operations = []; + const title = get( + docs, + '[0]["http://www.w3.org/ns/hydra/core#title"][0]["@value"]', + "API Platform" ); - } - // Add resources - for (const properties of entrypointClass[ - "http://www.w3.org/ns/hydra/core#supportedProperty" - ]) { - const readableFields = [], - resourceFields = [], - writableFields = [], - resourceOperations = []; - - const property = get( - properties, - '["http://www.w3.org/ns/hydra/core#property"][0]' - ); - if (!property) { - continue; + const entrypointType = get(entrypoint, '[0]["@type"][0]'); + if (!entrypointType) { + throw new Error('The API entrypoint has no "@type" key.'); + } + + const entrypointClass = findSupportedClass(docs, entrypointType); + if ( + !Array.isArray( + entrypointClass["http://www.w3.org/ns/hydra/core#supportedProperty"] + ) + ) { + throw new Error( + 'The entrypoint definition has no "http://www.w3.org/ns/hydra/core#supportedProperty" key or it is not an array.' + ); } - // Add fields - const relatedClass = findRelatedClass(docs, property); - for (const supportedProperties of relatedClass[ + // Add resources + for (const properties of entrypointClass[ "http://www.w3.org/ns/hydra/core#supportedProperty" ]) { - const supportedProperty = get( - supportedProperties, + const readableFields = [], + resourceFields = [], + writableFields = [], + resourceOperations = []; + + const property = get( + properties, '["http://www.w3.org/ns/hydra/core#property"][0]' ); - const range = get( - supportedProperty, - '["http://www.w3.org/2000/01/rdf-schema#range"][0]["@id"]', - null - ); + if (!property) { + continue; + } - const field = new Field( - supportedProperty["http://www.w3.org/2000/01/rdf-schema#label"][0][ - "@value" - ], - { - id: supportedProperty["@id"], - range: range, - reference: - "http://www.w3.org/ns/hydra/core#Link" === - get(property, '["@type"][0]') - ? range - : null, // Will be updated in a subsequent pass - required: get( - supportedProperties, - '["http://www.w3.org/ns/hydra/core#required"][0]["@value"]', - false - ), - description: get( - supportedProperties, - '["http://www.w3.org/ns/hydra/core#description"][0]["@value"]', - "" - ), - maxCardinality: get( - supportedProperty, - '["http://www.w3.org/2002/07/owl#maxCardinality"][0]["@value"]', - null - ), - deprecated: get( + // Add fields + const relatedClass = findRelatedClass(docs, property); + for (const supportedProperties of relatedClass[ + "http://www.w3.org/ns/hydra/core#supportedProperty" + ]) { + const supportedProperty = get( + supportedProperties, + '["http://www.w3.org/ns/hydra/core#property"][0]' + ); + const range = get( + supportedProperty, + '["http://www.w3.org/2000/01/rdf-schema#range"][0]["@id"]', + null + ); + + const field = new Field( + supportedProperty[ + "http://www.w3.org/2000/01/rdf-schema#label" + ][0]["@value"], + { + id: supportedProperty["@id"], + range: range, + reference: + "http://www.w3.org/ns/hydra/core#Link" === + get(property, '["@type"][0]') + ? range + : null, // Will be updated in a subsequent pass + required: get( + supportedProperties, + '["http://www.w3.org/ns/hydra/core#required"][0]["@value"]', + false + ), + description: get( + supportedProperties, + '["http://www.w3.org/ns/hydra/core#description"][0]["@value"]', + "" + ), + maxCardinality: get( + supportedProperty, + '["http://www.w3.org/2002/07/owl#maxCardinality"][0]["@value"]', + null + ), + deprecated: get( + supportedProperties, + '["http://www.w3.org/2002/07/owl#deprecated"][0]["@value"]', + false + ) + } + ); + + fields.push(field); + resourceFields.push(field); + + if ( + get( supportedProperties, - '["http://www.w3.org/2002/07/owl#deprecated"][0]["@value"]', - false + '["http://www.w3.org/ns/hydra/core#readable"][0]["@value"]' ) + ) { + readableFields.push(field); } - ); - - fields.push(field); - resourceFields.push(field); - if ( - get( - supportedProperties, - '["http://www.w3.org/ns/hydra/core#readable"][0]["@value"]' - ) - ) { - readableFields.push(field); + if ( + get( + supportedProperties, + '["http://www.w3.org/ns/hydra/core#writable"][0]["@value"]' + ) + ) { + writableFields.push(field); + } } - if ( - get( - supportedProperties, - '["http://www.w3.org/ns/hydra/core#writable"][0]["@value"]' - ) - ) { - writableFields.push(field); + // parse entrypoint's operations (a.k.a. collection operations) + if (property["http://www.w3.org/ns/hydra/core#supportedOperation"]) { + for (const entrypointOperation of property[ + "http://www.w3.org/ns/hydra/core#supportedOperation" + ]) { + if ( + !entrypointOperation["http://www.w3.org/ns/hydra/core#returns"] + ) { + continue; + } + + const range = + entrypointOperation[ + "http://www.w3.org/ns/hydra/core#returns" + ][0]["@id"]; + const operation = new Operation( + entrypointOperation[ + "http://www.w3.org/2000/01/rdf-schema#label" + ][0]["@value"], + { + method: + entrypointOperation[ + "http://www.w3.org/ns/hydra/core#method" + ][0]["@value"], + expects: + entrypointOperation[ + "http://www.w3.org/ns/hydra/core#expects" + ] && + entrypointOperation[ + "http://www.w3.org/ns/hydra/core#expects" + ][0]["@id"], + returns: range, + types: entrypointOperation["@type"], + deprecated: get( + entrypointOperation, + '["http://www.w3.org/2002/07/owl#deprecated"][0]["@value"]', + false + ) + } + ); + + resourceOperations.push(operation); + operations.push(operation); + } } - } - // parse entrypoint's operations (a.k.a. collection operations) - if (property["http://www.w3.org/ns/hydra/core#supportedOperation"]) { - for (const entrypointOperation of property[ + // parse resource operations (a.k.a. item operations) + for (const supportedOperation of relatedClass[ "http://www.w3.org/ns/hydra/core#supportedOperation" ]) { if ( - !entrypointOperation["http://www.w3.org/ns/hydra/core#returns"] + !supportedOperation["http://www.w3.org/ns/hydra/core#returns"] ) { continue; } const range = - entrypointOperation["http://www.w3.org/ns/hydra/core#returns"][0][ + supportedOperation["http://www.w3.org/ns/hydra/core#returns"][0][ "@id" ]; const operation = new Operation( - entrypointOperation[ + supportedOperation[ "http://www.w3.org/2000/01/rdf-schema#label" ][0]["@value"], { method: - entrypointOperation[ + supportedOperation[ "http://www.w3.org/ns/hydra/core#method" ][0]["@value"], expects: - entrypointOperation[ + supportedOperation[ "http://www.w3.org/ns/hydra/core#expects" ] && - entrypointOperation[ + supportedOperation[ "http://www.w3.org/ns/hydra/core#expects" ][0]["@id"], returns: range, - types: entrypointOperation["@type"], + types: supportedOperation["@type"], deprecated: get( - entrypointOperation, + supportedOperation, '["http://www.w3.org/2002/07/owl#deprecated"][0]["@value"]', false ) @@ -350,93 +397,57 @@ export default function parseHydraDocumentation(entrypointUrl, options = {}) { resourceOperations.push(operation); operations.push(operation); } - } - // parse resource operations (a.k.a. item operations) - for (const supportedOperation of relatedClass[ - "http://www.w3.org/ns/hydra/core#supportedOperation" - ]) { - if (!supportedOperation["http://www.w3.org/ns/hydra/core#returns"]) { - continue; + const url = get(entrypoint, `[0]["${property["@id"]}"][0]["@id"]`); + if (!url) { + throw new Error(`Unable to find the URL for "${property["@id"]}".`); } - const range = - supportedOperation["http://www.w3.org/ns/hydra/core#returns"][0][ - "@id" - ]; - const operation = new Operation( - supportedOperation["http://www.w3.org/2000/01/rdf-schema#label"][0][ - "@value" - ], - { - method: - supportedOperation["http://www.w3.org/ns/hydra/core#method"][0][ - "@value" - ], - expects: - supportedOperation["http://www.w3.org/ns/hydra/core#expects"] && - supportedOperation[ - "http://www.w3.org/ns/hydra/core#expects" - ][0]["@id"], - returns: range, - types: supportedOperation["@type"], + resources.push( + new Resource(guessNameFromUrl(url, entrypointUrl), url, { + id: relatedClass["@id"], + title: get( + relatedClass, + '["http://www.w3.org/ns/hydra/core#title"][0]["@value"]', + "" + ), + fields: resourceFields, + readableFields, + writableFields, + operations: resourceOperations, deprecated: get( - supportedOperation, + relatedClass, '["http://www.w3.org/2002/07/owl#deprecated"][0]["@value"]', false - ) - } + ), + parameters: [] + }) ); - - resourceOperations.push(operation); - operations.push(operation); } - const url = get(entrypoint, `[0]["${property["@id"]}"][0]["@id"]`); - if (!url) { - throw new Error(`Unable to find the URL for "${property["@id"]}".`); - } - - resources.push( - new Resource(guessNameFromUrl(url, entrypointUrl), url, { - id: relatedClass["@id"], - title: get( - relatedClass, - '["http://www.w3.org/ns/hydra/core#title"][0]["@value"]', - "" - ), - fields: resourceFields, - readableFields, - writableFields, - operations: resourceOperations, - deprecated: get( - relatedClass, - '["http://www.w3.org/2002/07/owl#deprecated"][0]["@value"]', - false - ) - }) - ); - } - - // Resolve references - for (const field of fields) { - if (null !== field.reference) { - field.reference = - resources.find(resource => resource.id === field.reference) || null; + // Resolve references + for (const field of fields) { + if (null !== field.reference) { + field.reference = + resources.find(resource => resource.id === field.reference) || + null; + } } - } - return Promise.resolve({ - api: new Api(entrypointUrl, { title, resources }), - response, - status: response.status - }); - }, - ({ response }) => - Promise.reject({ - api: new Api(entrypointUrl, { resources: [] }), - response, - status: get(response, "status") - }) - ); + return Promise.resolve({ + api: new Api(entrypointUrl, { title, resources }), + response, + status: response.status + }); + }, + ({ response }) => + Promise.reject({ + api: new Api(entrypointUrl, { resources: [] }), + response, + status: get(response, "status") + }) + ) + .then(({ api, response, status }) => + addParameters(api).then(api => ({ api, response, status })) + ); } diff --git a/src/hydra/parseHydraDocumentation.test.js b/src/hydra/parseHydraDocumentation.test.js index cd5e8f7..42ec853 100644 --- a/src/hydra/parseHydraDocumentation.test.js +++ b/src/hydra/parseHydraDocumentation.test.js @@ -552,6 +552,25 @@ const docs = `{ ] }`; +const resourceCollection = `{ + "hydra:search": { + "hydra:mapping": [] + } +}`; + +const resourceCollectionWithParameters = `{ + "hydra:search": { + "hydra:mapping": [ + { + "property": "isbn", + "variable": "isbn", + "range": "http://www.w3.org/2001/XMLSchema#string", + "required": false + } + ] + } +}`; + const book = { name: "books", url: "http://localhost/books", @@ -743,7 +762,15 @@ const book = { deprecated: false } ], - deprecated: false + deprecated: false, + parameters: [ + { + variable: "isbn", + range: "http://www.w3.org/2001/XMLSchema#string", + required: false, + description: "" + } + ] }; const review = { @@ -886,7 +913,8 @@ const review = { deprecated: false } ], - deprecated: false + deprecated: false, + parameters: [] }; const customResource = { @@ -1012,7 +1040,8 @@ const customResource = { deprecated: false } ], - deprecated: false + deprecated: false, + parameters: [] }; const deprecatedResource = { @@ -1072,13 +1101,16 @@ const deprecatedResource = { deprecated: true } ], - deprecated: true + deprecated: true, + parameters: [] }; +const resources = [book, review, customResource, deprecatedResource]; + const expectedApi = { entrypoint: "http://localhost", title: "API Platform's demo", - resources: [book, review, customResource, deprecatedResource] + resources: resources }; const init = { @@ -1091,30 +1123,45 @@ const init = { }) }; -test("parse a Hydra documentation", () => { - fetch.mockResponses([entrypoint, init], [docs, init]); +test("parse a Hydra documentation", async () => { + fetch.mockResponses( + [entrypoint, init], + [docs, init], + [resourceCollectionWithParameters, init], + [resourceCollection, init], + [resourceCollection, init], + [resourceCollection, init] + ); const options = { headers: new Headers({ CustomHeader: "customValue" }) }; - return parseHydraDocumentation("http://localhost", options).then(data => { + await parseHydraDocumentation("http://localhost", options).then(data => { expect(JSON.stringify(data.api, null, 2)).toBe( JSON.stringify(expectedApi, null, 2) ); expect(data.response).toBeDefined(); expect(data.status).toBe(200); - expect(fetch).toHaveBeenCalledTimes(2); - expect(fetch).toHaveBeenLastCalledWith( + expect(fetch).toHaveBeenCalledTimes(2 + resources.length); + expect(fetch).toHaveBeenNthCalledWith( + 2, "http://localhost/docs.jsonld", options ); }); }); -test("parse a Hydra documentation (http://localhost/)", () => { - fetch.mockResponses([entrypoint, init], [docs, init]); +test("parse a Hydra documentation (http://localhost/)", async () => { + fetch.mockResponses( + [entrypoint, init], + [docs, init], + [resourceCollectionWithParameters, init], + [resourceCollection, init], + [resourceCollection, init], + [resourceCollection, init] + ); - return parseHydraDocumentation("http://localhost/").then(data => { + await parseHydraDocumentation("http://localhost/").then(data => { expect(JSON.stringify(data.api, null, 2)).toBe( JSON.stringify(expectedApi, null, 2) );