diff --git a/demo/apis.json b/demo/apis.json index d134377..b75262e 100644 --- a/demo/apis.json +++ b/demo/apis.json @@ -29,5 +29,6 @@ "shopper-products/shopper-products.yaml": { "type": "OAS 3.0", "mime": "application/yaml" }, "W-12142859/W-12142859.yaml": "OAS 2.0", "nulleable/nulleable.yaml": "OAS 3.0", - "nullable-test/nullable-test.yaml": { "type": "OAS 3.0", "mime": "application/yaml" } + "nullable-test/nullable-test.yaml": { "type": "OAS 3.0", "mime": "application/yaml" }, + "nested-examples/nested-examples-oas3.json": { "type": "OAS 3.0", "mime": "application/json" } } diff --git a/demo/index.js b/demo/index.js index 94d02a8..cb8aee2 100644 --- a/demo/index.js +++ b/demo/index.js @@ -99,6 +99,7 @@ class ApiDemo extends ApiDemoPage { _apiListTemplate() { return [ + ['nested-examples-oas3', 'Nested Examples'], ['nullable-test', 'Nullable Test (Comprehensive)'], ['nulleable', 'Nulleable test'], ['grpc-test', 'GRPC test'], diff --git a/demo/nested-examples/nested-examples-oas3.json b/demo/nested-examples/nested-examples-oas3.json new file mode 100644 index 0000000..a58e1de --- /dev/null +++ b/demo/nested-examples/nested-examples-oas3.json @@ -0,0 +1,472 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "netsted-examples-oas3" + }, + "paths": { + "/productOrderItems": { + "get": { + "summary": "List product order items", + "operationId": "listProductOrderItems", + "responses": { + "200": { + "description": "List of product order items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProductOrderItem" + } + }, + "examples": { + "default": { + "summary": "Full productOrderItem example list (300+ lines)", + "value": [ + { + "state": "acknowledged", + "@type": "productOrderItem", + "productOrderItemRelationship": [ + { + "id": "2", + "relationshipType": "isParent", + "@type": "OrderItemRelationship" + } + ], + "product": { + "@type": "product", + "productCharacteristic": [ + { + "valueType": "string", + "value": "80/20", + "name": "productSpeed", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "Restart", + "name": "provisioningCommand", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "Standard", + "name": "careLevel", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "Managed Standard", + "name": "installationType", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "Jane-Kelly", + "name": "installationContactNamePrimary", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "07449929291", + "name": "installationContactNumberPrimary", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "Jane", + "name": "installationContactNameSecondary", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "07449929292", + "name": "installationContactNumberSecondary", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "cgiuk@tt.com", + "name": "installationContactEmail", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "HD05091996", + "name": "partnerOrderReference", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "Static IP - 4", + "name": "ipBlockSize", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "abclub.net", + "name": "domainName", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "CCF", + "name": "retailerId", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "test test", + "name": "otherUses", + "@type": "StringCharacteristic" + }, + { + "valueType": "string", + "value": "test test", + "name": "otherDescription", + "@type": "StringCharacteristic" + } + ], + "place": [ + { + "@type": "place", + "role": "installationAddress", + "place": { + "postcode": "M5 3BL", + "@type": "PXCGeographicSubAddressUnit", + "externalId": [ + { + "@type": "ExternalIdentifier", + "externalIdentifierType": "galk", + "id": "A90000791299" + }, + { + "@type": "ExternalIdentifier", + "externalIdentifierType": "districtCode", + "id": "LV" + } + ] + } + } + ], + "name": "C-OR-SOGEA" + }, + "appointment": { + "date": "2024-09-30", + "timeSlot": "AM", + "id": "48160", + "@type": "appointment" + }, + "action": "add", + "id": "0001" + }, + { + "appointment": { + "date": "2024-09-30", + "timeSlot": "AM", + "id": "50795", + "@type": "appointment" + }, + "state": "acknowledged", + "@type": "productOrderItem", + "productOrderItemRelationship": [ + { + "id": "1", + "relationshipType": "isChild", + "@type": "OrderItemRelationship" + } + ], + "product": { + "relatedParty": [ + { + "title": "Mr", + "otherName": "John", + "familyName": "Smith", + "role": "edb", + "@type": "IndividualWithAddress", + "place": { + "buildingName": "PX Apartments", + "subUnitNumber": "FLAT3", + "streetNr": "150", + "streetName": "Warburton Street", + "subStreetName": "behind Queen Vic pub", + "locality": "Manchester", + "dependentLocality": "Salford", + "doubleDependentLocality": "Ordsall", + "postcode": "M5 3BL", + "@type": "PXCGeographicSubAddressUnit" + } + }, + { + "name": "Alphabeta", + "otherName": "PlatformX Communications", + "nameType": "Ltd", + "role": "dq", + "@type": "OrganizationWithAddress", + "place": { + "buildingName": "Alphabeta", + "subUnitNumber": "FLAT6", + "streetNr": "14-18", + "streetName": "Finsbury Square", + "subStreetName": "", + "locality": "London", + "dependentLocality": "Hackney", + "doubleDependentLocality": "Shoreditch", + "postcode": "EC2A 1BR", + "@type": "PXCGeographicSubAddressUnit" + }, + "partyCharacteristic": [ + { + "name": "directoryEntryLineUse", + "value": "NormalUse", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "directoryEntryPreference", + "value": "Normal", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "directoryEntryType", + "value": "NewDQ", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "directoryEntryFilePlacement", + "value": "Residential", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "directoryEntryPartialAddressIndicator", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + } + ] + } + ], + "productCharacteristic": [ + { + "name": "requestedTelephoneNumber", + "value": "01123456789", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "lcpCupid", + "value": "", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "oldPostCode", + "value": "", + "valueType": "String", + "@type": "StringCharacteristic" + }, + { + "name": "newNumberOverrideAllowed", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "anonymousCallRejection", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "answerService1571RemoteAccess", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "answerService1571", + "value": "Basic", + "valueType": "string", + "@type": "StringCharacteristic" + }, + { + "name": "callerDisplay", + "value": true, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "chooseToRefuse", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "cliPresentationRestrictionPermanent", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "cliRetrieval", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "subscriberCallForward", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "subscriberCallForwardRemoteAccess", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "countrySpecificBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "directoryEnquiriesBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "highRiskInternationalBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "internationalBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "mobileBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "nationalBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "premiumRateLowBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "premiumRateHighBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "premiumRateAdultBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "servicesLowBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "servicesHighBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "servicesFreeBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "specialServicesBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + }, + { + "name": "subscriberOutgoingCallBar", + "value": false, + "valueType": "boolean", + "@type": "BooleanCharacteristic" + } + ], + "productOffering": { + "name": "Pay as you go", + "id": "PAYG", + "@type": "productOffering" + }, + "name": "C-VOIP", + "@type": "product" + }, + "action": "add", + "id": "0001" + } + ] + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ProductOrderItem": { + "type": "object", + "description": "An identified part of the order (product order item).", + "properties": { + "id": { + "type": "string" + }, + "action": { + "type": "string" + }, + "state": { + "type": "string" + }, + "@type": { + "type": "string" + }, + "product": { + "type": "object" + }, + "productOrderItemRelationship": { + "type": "array", + "items": { + "type": "object" + } + }, + "appointment": { + "type": "object" + } + } + } + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7e4915f..0316d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@api-components/api-type-document", - "version": "4.2.38", + "version": "4.2.39", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@api-components/api-type-document", - "version": "4.2.38", + "version": "4.2.39", "license": "Apache-2.0", "dependencies": { "@advanced-rest-client/arc-marked": "^1.1.0", diff --git a/package.json b/package.json index bf821e1..33ac7ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@api-components/api-type-document", "description": "A documentation table for type (resource) properties. Works with AMF data model", - "version": "4.2.38", + "version": "4.2.39", "license": "Apache-2.0", "main": "index.js", "module": "index.js", diff --git a/src/ApiTypeDocument.js b/src/ApiTypeDocument.js index d26732b..e27ca7f 100644 --- a/src/ApiTypeDocument.js +++ b/src/ApiTypeDocument.js @@ -391,6 +391,7 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) { this._showExamples = !this.noMainExample && ( this.renderMediaSelector || this.isObject || + this.isArray || this._renderMainExample ); } @@ -530,15 +531,16 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) { } // Determine if we should show the examples section - // Priority: noMainExample (hide) > renderMediaSelector (show) > isObject (show) > _renderMainExample + // Priority: noMainExample (hide) > renderMediaSelector (show) > isObject (show) > isArray (show) > _renderMainExample this._showExamples = !this.noMainExample && ( this.renderMediaSelector || // Need to show the section for the media type selector isObject || // Objects can generate examples automatically + isArray || // Arrays can generate examples automatically this._renderMainExample // Has explicit examples ); - // Effective media type - use 'application/json' as default for objects without mediaType - this._exampleMediaType = this.mediaType || (isObject ? 'application/json' : undefined); + // Effective media type - use 'application/json' as default for objects and arrays without mediaType + this._exampleMediaType = this.mediaType || (isObject || isArray ? 'application/json' : undefined); } /** @@ -1141,7 +1143,7 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) { : this._renderMainExample; const exampleMediaType = this._exampleMediaType !== undefined ? this._exampleMediaType - : (this.mediaType || (this.isObject ? 'application/json' : undefined)); + : (this.mediaType || (this.isObject || this.isArray ? 'application/json' : undefined)); return html` ${shouldRenderExamples ? html`
diff --git a/test/nested-array-examples.test.js b/test/nested-array-examples.test.js new file mode 100644 index 0000000..415b7b6 --- /dev/null +++ b/test/nested-array-examples.test.js @@ -0,0 +1,200 @@ +/* eslint-disable prefer-destructuring */ +import { fixture, assert, aTimeout, nextFrame } from '@open-wc/testing' +import { AmfLoader } from './amf-loader.js'; +import '../api-type-document.js'; + +/** @typedef {import('..').ApiTypeDocument} ApiTypeDocument */ +/** @typedef {import('@api-components/amf-helper-mixin').AmfDocument} AmfDocument */ + +/** + * Test for array response examples in OAS 3.0. + * + * This test reproduces the bug where example payloads were not displaying + * on the Exchange main page for APIs using OpenAPI Specification (OAS) 3.0.0 + * when the response schema is an array type. + * + * The issue affected partner automation for order placement and was caused by + * the component not recognizing array types as eligible for example rendering, + * only checking for object types. + * + * Bug details: + * - Started: January 30, 2026 + * - Impact: All OAS 3.0.0 specifications with array response schemas + * - Examples visible in Design Center but failed to render in Exchange + * - RAML specifications were unaffected + * + * Fix: Added isArray checks alongside isObject checks in: + * - _showExamples computation + * - _exampleMediaType defaulting (both should default to 'application/json') + */ +describe('Array Examples - OAS 3.0 (nested-examples)', () => { + const file = 'nested-examples-oas3'; + + /** + * @returns {Promise} + */ + async function basicFixture() { + return fixture(``); + } + + /** + * Get response payload schema for a specific endpoint and operation + * @param {AmfDocument} model + * @param {string} endpoint + * @param {string} operation + * @returns {any} + */ + function getResponsePayloadSchema(model, endpoint, operation) { + const op = AmfLoader.lookupOperation(model, endpoint, operation); + + // Find the response with status 200 + const returnsKey = Object.keys(op).find(k => k.includes('returns')); + if (!returnsKey) return null; + + const responses = op[returnsKey]; + let response200; + + for (const resp of responses) { + const statusKey = Object.keys(resp).find(k => k.includes('statusCode')); + if (statusKey && resp[statusKey]) { + const statusValue = Array.isArray(resp[statusKey]) ? resp[statusKey][0] : resp[statusKey]; + const status = typeof statusValue === 'object' ? statusValue['@value'] : statusValue; + if (status === '200') { + response200 = resp; + break; + } + } + } + + if (!response200) return null; + + // Get payload + const payloadKey = Object.keys(response200).find(k => k.includes('payload')); + if (!payloadKey) return null; + + const payloads = response200[payloadKey]; + const payload = Array.isArray(payloads) ? payloads[0] : payloads; + + // Get schema + const schemaKey = Object.keys(payload).find(k => k.includes('schema')); + if (!schemaKey) return null; + + const schemas = payload[schemaKey]; + return Array.isArray(schemas) ? schemas[0] : schemas; + } + + [ + ['Regular model', false], + ['Compact model', true], + ].forEach((item) => { + describe(String(item[0]), () => { + let element = /** @type ApiTypeDocument */ (null); + let amf; + + before(async () => { + amf = await AmfLoader.load(item[1], file); + }); + + beforeEach(async () => { + element = await basicFixture(); + }); + + it('loads the nested-examples-oas3 model', async () => { + assert.exists(amf, 'AMF model should be loaded'); + }); + + it('identifies array response schema correctly', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + assert.exists(responseSchema, 'Response schema should exist'); + + element.amf = amf; + element.type = responseSchema; + await aTimeout(0); + + // Verify it's recognized as an array type + assert.isTrue(element.isArray, 'Response schema should be identified as array'); + }); + + it('renders examples section for array responses', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + + element.amf = amf; + element.type = responseSchema; + await aTimeout(0); + + // Verify the examples section is rendered + const examplesSection = element.shadowRoot.querySelector('.examples'); + assert.exists(examplesSection, 'Examples section should be rendered for array responses'); + }); + + it('passes resolved type to api-resource-example-document for arrays', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + + element.amf = amf; + element.type = responseSchema; + await aTimeout(0); + + const exampleDoc = element.shadowRoot.querySelector('api-resource-example-document'); + assert.exists(exampleDoc, 'api-resource-example-document should be rendered'); + assert.isDefined(exampleDoc.examples, 'Examples should be passed to the component'); + }); + + it('uses default mediaType for arrays without explicit mediaType', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + + element.amf = amf; + element.type = responseSchema; + // Don't set mediaType explicitly + await aTimeout(0); + + const exampleDoc = element.shadowRoot.querySelector('api-resource-example-document'); + assert.exists(exampleDoc, 'api-resource-example-document should exist'); + assert.equal( + exampleDoc.mediaType, + 'application/json', + 'Should default to application/json for arrays' + ); + }); + + it('sets _showExamples to true for array types', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + + element.amf = amf; + element.type = responseSchema; + await aTimeout(0); + + assert.isTrue(element._showExamples, '_showExamples should be true for arrays'); + }); + + it('respects noMainExample flag for arrays', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + + element.amf = amf; + element.noMainExample = true; + element.type = responseSchema; + await element.updateComplete; + await nextFrame(); + + const examplesSection = element.shadowRoot.querySelector('.examples'); + assert.notExists(examplesSection, 'Examples should not render when noMainExample is true'); + }); + + it('renders complex nested array examples correctly', async () => { + const responseSchema = getResponsePayloadSchema(amf, '/productOrderItems', 'get'); + + element.amf = amf; + element.type = responseSchema; + element.mediaType = 'application/json'; + await aTimeout(0); + + const exampleDoc = element.shadowRoot.querySelector('api-resource-example-document'); + assert.exists(exampleDoc, 'Example document should be rendered'); + + // Verify the component received the correct props + assert.equal(exampleDoc.mediaType, 'application/json'); + assert.isDefined(exampleDoc.examples, 'Examples should be defined'); + assert.exists(element.shadowRoot.querySelector('.examples'), 'Examples section should exist'); + }); + }); + }); +});