diff --git a/README.md b/README.md index 83c82eb..a4ef905 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,46 @@ import parseSwaggerDocumentation from 'api-doc-parser/lib/swagger/parseSwaggerDo parseSwaggerDocumentation('https://demo.api-platform.com/docs.json').then(({api}) => console.log(api)); ``` +### Serialization + +In order to allow caching (e.g. for performance or offline fallback purpose) you can utilize the `ApiSerializer` which can serialize the `Api` object graph to a plain javascript object tree which can be json-serialized easily (the `Api` object graph may have circular references which means it is in some circumstances not json-serializable as is). + +```javascript +import parseHydraDocumentation from 'api-doc-parser/lib/hydra/parseHydraDocumentation'; +import ApiSerializer from 'api-doc-parse/lib/ApiSerializer'; + +parseHydraDocumentation('https://demo.api-platform.com').then(({api}) => { + const serializer = new ApiSerializer(); + const serialized = serializer.serialize(api); + + console.log(JSON.stringify(serialized)); +}); +``` + +A scenario where you'd like to utilize some storage (e.g. `localStorage`) for caching you could implement something like this: + +```javascript +import parseHydraDocumentation from 'api-doc-parser/lib/hydra/parseHydraDocumentation'; +import ApiSerializer from 'api-doc-parse/lib/ApiSerializer'; + +const getApiSpecs = () => new Promise(resolve => { + const serializer = new ApiSerializer(); + const serializedSpecs = localStorage.getItem('apiSpecs'); + + if (serializedSpecs) { + resolve(serializer.deserialize(serializedSpecs)); + } else { + parseHydraDocumentation('https://demo.api-platform.com').then(({api}) => { + localStorage.setItem('apiSpecs', serializer.serialize(api)); + + resolve(api); + }); + } +}); + +getApiSpecs().then(specs => console.log(specs)); +``` + ## Support for other formats (GraphQL, JSONAPI...) API Doc Parser is designed to parse any API documentation format and convert it in the same intermediate representation. diff --git a/src/Api.js b/src/Api.js index df17533..57f45b2 100644 --- a/src/Api.js +++ b/src/Api.js @@ -12,6 +12,8 @@ type ApiOptions = { */ export default class Api { entrypoint: string; + title: string; + resources: Array; /** * @param {string} entrypoint diff --git a/src/ApiSerializer.js b/src/ApiSerializer.js new file mode 100644 index 0000000..32e6e2b --- /dev/null +++ b/src/ApiSerializer.js @@ -0,0 +1,163 @@ +// @flow + +import Api from "./Api"; +import Resource from "./Resource"; +import Field from "./Field"; +import Operation from "./Operation"; +import Parameter from "./Parameter"; + +export default class ApiSerializer { + /** + * @param {Api} api + * @return {object} a POJO + */ + serialize(api: Api) { + const { resources, ...rest } = api; + + return { + ...rest, + resources: resources.map(this.serializeResource) + }; + } + + serializeResource = (resource: Resource) => { + const { + readableFields, + writableFields, + operations, + parameters, + ...rest + } = resource; + const result = { + ...rest + }; + + if (readableFields) { + result.readableFields = readableFields.map(this.serializeField); + } + if (writableFields) { + result.writableFields = writableFields.map(this.serializeField); + } + if (operations) { + result.operations = operations.map(this.serializeOperation); + } + if (parameters) { + result.parameters = parameters.map(this.serializeParameter); + } + + return result; + }; + + serializeField = (field: Field) => { + const { reference, ...rest } = field; + return { + ...rest, + reference: + reference && reference instanceof Resource ? reference.id : null + }; + }; + + serializeOperation = (operation: Operation) => { + return { + ...operation + }; + }; + + serializeParameter = (parameter: Parameter) => { + return { + ...parameter + }; + }; + + /** + * @param {object} data the serialized POJO + * @return {Api|false} + */ + deserialize(data) { + const { resources = [], entrypointUrl, ...rest } = data; + const preparedResources = []; + const allFields = []; + const allOperations = []; + const allParameters = []; + + if (!resources) return false; + + for (const resourceData of resources) { + const { + name, + url, + readableFields, + writableFields, + operations, + parameters, + ...resourceRest + } = resourceData; + const resourceOptions = { ...resourceRest }; + const resourceReadableFields = []; + const resourceWritableFields = []; + const resourceOperations = []; + const resourceParameters = []; + + if (readableFields) { + for (const { fieldName, ...fieldOptions } of readableFields) { + const field = new Field(fieldName, fieldOptions); + resourceReadableFields.push(field); + allFields.push(field); + } + + resourceOptions.readableFields = resourceReadableFields; + } + + if (writableFields) { + for (const { fieldName, ...fieldOptions } of writableFields) { + const field = new Field(fieldName, fieldOptions); + resourceWritableFields.push(field); + allFields.push(field); + } + + resourceOptions.writableFields = resourceWritableFields; + } + + if (operations) { + for (const { operationName, ...operationOptions } of operations) { + const operation = new Operation(operationName, operationOptions); + resourceOperations.push(operation); + allOperations.push(operation); + } + + resourceOptions.operations = resourceOperations; + } + + if (parameters) { + for (const { variable, range, required, description } of parameters) { + const parameter = new Parameter( + variable, + range, + required, + description + ); + resourceParameters.push(parameter); + allParameters.push(parameter); + } + + resourceOptions.parameters = resourceParameters; + } + + preparedResources.push(new Resource(name, url, resourceOptions)); + } + + // Resolve references + for (const field of allFields) { + if (null !== field.reference) { + field.reference = + preparedResources.find(resource => resource.id === field.reference) || + null; + } + } + + return new Api(entrypointUrl, { + ...rest, + resources: preparedResources + }); + } +} diff --git a/src/ApiSerializer.test.js b/src/ApiSerializer.test.js new file mode 100644 index 0000000..9071e2c --- /dev/null +++ b/src/ApiSerializer.test.js @@ -0,0 +1,144 @@ +import Api from "./Api"; +import ApiSerializer from "./ApiSerializer"; +import Resource from "./Resource"; +import Field from "./Field"; +import Operation from "./Operation"; +import Parameter from "./Parameter"; + +test("can serialize and deserialize Api", () => { + const book = new Resource("Books", "http://localhost/books", { + id: "http://schema.org/Book", + title: "Book", + readableFields: [ + new Field("isbn", { + id: "http://schema.org/isbn", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: true, + description: "The ISBN of the book", + maxCardinality: null, + deprecated: false + }), + new Field("name", { + id: "http://schema.org/name", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: true, + description: "The name of the item", + maxCardinality: null, + deprecated: false + }) + ], + writableFields: [ + new Field("isbn", { + id: "http://schema.org/isbn", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: true, + description: "The ISBN of the book", + maxCardinality: null, + deprecated: false + }), + new Field("name", { + id: "http://schema.org/name", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: true, + description: "The name of the item", + maxCardinality: null, + deprecated: false + }) + ], + operations: [ + new Operation("Retrieves Book resource.", { + method: "GET", + returns: "http://schema.org/Book", + types: ["http://www.w3.org/ns/hydra/core#Operation"], + deprecated: false + }), + new Operation("Replaces the Book resource.", { + method: "PUT", + expects: "http://schema.org/Book", + returns: "http://schema.org/Book", + types: ["http://www.w3.org/ns/hydra/core#ReplaceResourceOperation"], + deprecated: false + }), + new Operation("Deletes the Book resource.", { + method: "DELETE", + returns: "http://www.w3.org/2002/07/owl#Nothing", + types: ["http://www.w3.org/ns/hydra/core#Operation"], + deprecated: false + }) + ], + parameters: [ + new Parameter( + "isbn", + "http://www.w3.org/2001/XMLSchema#string", + false, + "" + ) + ] + }); + + const review = new Resource("Reviews", "http://localhost/reviews", { + id: "http://schema.org/Review", + title: "Book", + readableFields: [ + new Field("reviewBody", { + id: "http://schema.org/reviewBody", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: false, + description: "The actual body of the review", + maxCardinality: null, + deprecated: false + }), + new Field("itemReviewed", { + id: "http://schema.org/itemReviewed", + range: "http://schema.org/Book", + reference: book, + required: true, + description: "The name of the item", + maxCardinality: null, + deprecated: false + }) + ], + writableFields: [ + new Field("reviewBody", { + id: "http://schema.org/reviewBody", + range: "http://www.w3.org/2001/XMLSchema#string", + reference: null, + required: true, + description: "The actual body of the review", + maxCardinality: null, + deprecated: false + }), + new Field("itemReviewed", { + id: "http://schema.org/itemReviewed", + range: "http://schema.org/Book", + reference: book, + required: true, + description: "The item that is being reviewed/rated", + maxCardinality: null, + deprecated: false + }) + ] + }); + + const api = new Api("http://localhost", { + title: "API Platform's demo", + resources: [book, review] + }); + + const serializer = new ApiSerializer(); + const serialized = serializer.serialize(api); + + // verify that the serialized and stringified versions don't differ + // this ensures that the serializer does not return stuff that cannot be + // expressed in json-string notation properly + expect(JSON.parse(JSON.stringify(serialized))).toEqual(serialized); + + const deserialized = serializer.deserialize(serialized); + + expect(deserialized).toEqual(api); +});