diff --git a/demo/apis.json b/demo/apis.json index ac9b1eb..bdde942 100644 --- a/demo/apis.json +++ b/demo/apis.json @@ -21,5 +21,6 @@ "agents-api/agents-api.yaml": { "type": "OAS 3.0", "mime": "application/yaml" }, "tags-flights/tags-flights.yml": { "type": "OAS 3.0", "mime": "application/yaml" }, "oas32-query/oas32-query.yaml": { "type": "OAS 3.2", "mime": "application/yaml" }, + "oas31-webhooks/oas31-webhooks.yaml": { "type": "OAS 3.1", "mime": "application/yaml" }, "grpc-test.json": { "type": "gRPC", "mime": "application/json" } } diff --git a/demo/index.js b/demo/index.js index aef01ca..45d6f8e 100644 --- a/demo/index.js +++ b/demo/index.js @@ -31,6 +31,7 @@ class ApiDemo extends ApiDemoPage { ["APIC-641", "APIC-641"], ["W-10881270", "W-10881270"], ["oas32-query", "OAS 3.2 (QUERY)"], + ["oas31-webhooks", "OAS 3.1 (Webhooks)"], ].map( ([file, label]) => html` ${label} diff --git a/demo/oas31-webhooks/oas31-webhooks.yaml b/demo/oas31-webhooks/oas31-webhooks.yaml new file mode 100644 index 0000000..8052546 --- /dev/null +++ b/demo/oas31-webhooks/oas31-webhooks.yaml @@ -0,0 +1,136 @@ +openapi: 3.1.0 +info: + title: Webhooks + Schema Composition Spike + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + summary: List pets + responses: + "200": + description: A list of pets + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + +# Top-level `webhooks` map of Path Item Objects, confirmed in OAS 3.1.0 spec +# (versions/3.1.0.md, field `webhooks`, sibling to `paths`/`components`). +webhooks: + newPet: + post: + operationId: newPetWebhook + summary: Notify subscriber of a newly created pet + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Webhook received successfully + +components: + schemas: + Pet: + # allOf: base fields + discriminated extra fields + allOf: + - $ref: "#/components/schemas/PetBase" + - $ref: "#/components/schemas/PetKindFields" + + PetBase: + type: object + required: [id, name] + properties: + id: + type: integer + name: + type: string + kind: + type: string + enum: [dog, cat] + + # if/then/else conditional (JSON Schema 2020-12, dialect used by OAS 3.1) + PetKindFields: + if: + properties: + kind: + const: dog + then: + properties: + breed: + type: string + required: [breed] + else: + properties: + indoor: + type: boolean + required: [indoor] + + # oneOf: mutually exclusive contact methods + ContactMethod: + oneOf: + - $ref: "#/components/schemas/EmailContact" + - $ref: "#/components/schemas/PhoneContact" + + EmailContact: + type: object + required: [email] + properties: + email: + type: string + format: email + + PhoneContact: + type: object + required: [phone] + properties: + phone: + type: string + + # anyOf: owner may have one or more identifiers present + OwnerIdentifiers: + anyOf: + - required: [ownerId] + properties: + ownerId: + type: integer + - required: [ownerEmail] + properties: + ownerEmail: + type: string + format: email + + # oneOf with inline anonymous object members (no $ref). AMF assigns synthetic + # names (item0/item1); the component must render positional "Option N" labels. + InlineChoice: + oneOf: + - type: object + properties: + a: + type: string + - type: object + properties: + b: + type: integer + + # oneOf whose first member is an inline anonymous scalar. The synthetic name + # must be replaced by the datatype label ("String"). + ScalarChoice: + oneOf: + - type: string + - type: integer + + # allOf mixing a named $ref (PetBase, preserved) with an inline anonymous + # member (synthetic name -> label nulled). + MixedAllOf: + allOf: + - $ref: "#/components/schemas/PetBase" + - type: object + properties: + extra: + type: string diff --git a/package-lock.json b/package-lock.json index b2638ff..70f02d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@api-components/api-summary", - "version": "4.6.20", + "version": "4.6.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@api-components/api-summary", - "version": "4.6.20", + "version": "4.6.21", "license": "Apache-2.0", "dependencies": { "@advanced-rest-client/arc-marked": "^1.1.2", "@advanced-rest-client/icons": "^4.0.2", "@advanced-rest-client/markdown-styles": "^3.1.5", - "@api-components/amf-helper-mixin": "^4.5.36", + "@api-components/amf-helper-mixin": "^4.5.38", "@api-components/api-method-documentation": "^5.2.5", "@api-components/api-model-generator": "^0.4.0", "@api-components/http-method-label": "^3.1.4", diff --git a/package.json b/package.json index 81c026f..abfd257 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@api-components/api-summary", "description": "A summary view for an API base on AMF data model", - "version": "4.6.20", + "version": "4.6.21", "license": "Apache-2.0", "main": "index.js", "module": "index.js", @@ -28,7 +28,7 @@ "@advanced-rest-client/arc-marked": "^1.1.2", "@advanced-rest-client/icons": "^4.0.2", "@advanced-rest-client/markdown-styles": "^3.1.5", - "@api-components/amf-helper-mixin": "^4.5.36", + "@api-components/amf-helper-mixin": "^4.5.38", "@api-components/api-method-documentation": "^5.2.5", "@api-components/api-model-generator": "^0.4.0", "@api-components/http-method-label": "^3.1.4", diff --git a/src/ApiSummary.d.ts b/src/ApiSummary.d.ts index 480ec51..43cebb1 100644 --- a/src/ApiSummary.d.ts +++ b/src/ApiSummary.d.ts @@ -35,6 +35,7 @@ export declare class ApiSummary extends AmfHelperMixin(LitElement) { _licenseName: string; _licenseUrl: string; _endpoints: any[]; + _webhooks: any[]; _termsOfService: string; _version: string; _apiTitle: string; @@ -96,6 +97,12 @@ export declare class ApiSummary extends AmfHelperMixin(LitElement) { */ _computeEndpoints(webApi: any): any[]|undefined; + /** + * Computes view model for the webhooks list (OAS 3.1/3.2 top-level webhooks). + * @param webApi Web API model + */ + _computeWebhooks(webApi: any): any[]|undefined; + /** * Computes a view model for supported operations for an endpoint. * @param {any} endpoint Endpoint model. @@ -142,6 +149,8 @@ export declare class ApiSummary extends AmfHelperMixin(LitElement) { _endpointsTemplate(): TemplateResult|string; + _webhooksTemplate(): TemplateResult|string; + _endpointTemplate(item): TemplateResult|string; _endpointPathTemplate(item): TemplateResult; diff --git a/src/ApiSummary.js b/src/ApiSummary.js index e362c8d..6d4c431 100644 --- a/src/ApiSummary.js +++ b/src/ApiSummary.js @@ -52,6 +52,7 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { _licenseName: { type: String }, _licenseUrl: { type: String }, _endpoints: { type: Array }, + _webhooks: { type: Array }, _termsOfService: { type: String }, _version: { type: String }, _apiTitle: { type: String }, @@ -134,6 +135,7 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { this._version = undefined; this._termsOfService = undefined; this._endpoints = undefined; + this._webhooks = undefined; this._providerName = undefined; this._providerEmail = undefined; @@ -149,6 +151,7 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { this._version = this._computeVersion(webApi); this._termsOfService = this._computeToS(webApi); this._endpoints = this._computeEndpoints(webApi); + this._webhooks = this._computeWebhooks(webApi); const provider = this._computeProvider(webApi); this._providerName = this._computeName(provider); @@ -318,6 +321,45 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { }); } + /** + * Computes view model for the webhooks list (OAS 3.1/3.2 top-level webhooks). + * Webhooks are `apiContract#EndPoint` nodes — the same shape as endpoints — so + * they reuse the endpoint item view model and `_endpointTemplate` for rendering. + * @param {any} webApi Web API model + * @return {any[]|undefined} + */ + _computeWebhooks(webApi) { + if (!webApi) { + return undefined; + } + const key = this._getAmfKey(this.ns.aml.vocabularies.apiContract.webhooks); + const webhooks = this._ensureArray(webApi[key]); + if (!webhooks || !webhooks.length) { + return undefined; + } + return webhooks.map((item) => { + const path = this._getValue( + item, + this.ns.aml.vocabularies.apiContract.path + ); + const name = this._getValue(item, this.ns.aml.vocabularies.core.name); + const pathStr = typeof path === "string" ? path : `/${name || ""}`; + const supportedOperations = this._endpointOperations(item); + const webhookDisplayInfo = this._computeEndpointName( + item, + webApi, + supportedOperations + ); + return { + name: webhookDisplayInfo?.name, + description: webhookDisplayInfo?.description, + path: pathStr, + id: item["@id"], + ops: supportedOperations, + }; + }); + } + /** * Computes a descriptive name for an endpoint based on its operations and tags. * @param {any} endpoint Endpoint model @@ -637,7 +679,8 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { ${this._licenseTemplate()} ${this._termsOfServiceTemplate()} - ${this.hideToc ? "" : this._endpointsTemplate()} `; + ${this.hideToc ? "" : this._endpointsTemplate()} + ${this.hideToc ? "" : this._webhooksTemplate()} `; } _titleTemplate() { @@ -879,6 +922,26 @@ export class ApiSummary extends AmfHelperMixin(LitElement) { `; } + /** + * @return {TemplateResult|string} A template for the top-level webhooks list + * (OAS 3.1/3.2). Webhooks share the endpoint item shape, so each renders with + * the same `_endpointTemplate`. + */ + _webhooksTemplate() { + const { _webhooks } = this; + if (!_webhooks || !_webhooks.length) { + return ""; + } + const result = _webhooks.map((item) => this._endpointTemplate(item)); + return html` +
+
+ + ${result} +
+ `; + } + _endpointTemplate(item) { const ops = item.ops && item.ops.length diff --git a/src/Styles.js b/src/Styles.js index 2858b3e..4ad181a 100644 --- a/src/Styles.js +++ b/src/Styles.js @@ -209,7 +209,8 @@ export default css` font-size: var(--api-summary-section-title-font-size); } - .section.endpoints-title { + .section.endpoints-title, + .section.webhooks-title { font-weight: var(--arc-font-title-font-weight, 500); color: var(--arc-font-title-color); font-weight: var(--arc-font-title-font-weight); diff --git a/test/api-summary.test.js b/test/api-summary.test.js index a04b2d5..d91ff51 100644 --- a/test/api-summary.test.js +++ b/test/api-summary.test.js @@ -369,6 +369,64 @@ describe('ApiSummary', () => { }); }); + describe('Webhooks rendering (OAS 3.1)', () => { + let element = /** @type ApiSummary */ (null); + let amf; + + before(async () => { + amf = await AmfLoader.load(compact, 'oas31-webhooks'); + }); + + beforeEach(async () => { + element = await basicFixture(); + element.amf = amf; + await aTimeout(0); + }); + + function webhooksToc() { + const titleNode = element.shadowRoot.querySelector('.webhooks-title'); + return titleNode ? titleNode.closest('.toc') : null; + } + + it('computes the webhooks view model', () => { + assert.typeOf(element._webhooks, 'array'); + assert.lengthOf(element._webhooks, 1); + }); + + it('renders a Webhooks section title', () => { + const node = element.shadowRoot.querySelector('.webhooks-title'); + assert.dom.equal(node, ``); + }); + + it('renders the webhook as an endpoint item', () => { + const nodes = webhooksToc().querySelectorAll('.endpoint-item'); + assert.lengthOf(nodes, 1); + }); + + it('renders the webhook operation method', () => { + const node = webhooksToc().querySelector('.method-label'); + assert.equal(node.getAttribute('data-method'), 'post'); + assert.equal(node.getAttribute('data-shape-type'), 'method'); + }); + + it('keeps endpoints and webhooks as separate sections', () => { + assert.ok(element.shadowRoot.querySelector('.endpoints-title'), 'has endpoints section'); + assert.ok(element.shadowRoot.querySelector('.webhooks-title'), 'has webhooks section'); + }); + + it('dispatches a navigation event when a webhook method is clicked', (done) => { + const node = webhooksToc().querySelector('.method-label[data-id]'); + element.addEventListener('api-navigation-selection-changed', (e) => { + // @ts-ignore + const { detail } = e; + assert.typeOf(detail.selected, 'string'); + assert.equal(detail.type, 'method'); + done(); + }); + /** @type HTMLElement */ (node).click(); + }); + }); + describe('Server rendering', () => { let ramlSingleServerAmf; let oasMultipleServersAmf; @@ -494,6 +552,7 @@ describe('ApiSummary', () => { assert.isUndefined(element._version); assert.isUndefined(element._termsOfService); assert.isUndefined(element._endpoints); + assert.isUndefined(element._webhooks); }); }); });