From e436220710727840c6993a7a7feb1b629e7f31f7 Mon Sep 17 00:00:00 2001 From: Casey Marshall Date: Thu, 30 Jan 2025 08:55:55 -0600 Subject: [PATCH] feat: allow namespaced component names OpenAPI generated by Typespec will use namespace-qualified component names in the generated OpenAPI, when types are in a different namespace from that of the OpenAPI spec. Additionally, components derived from model properties such as parameters or headers use the property name as a suffix. This updates the componentNameCase rule to allow PascalCase component names with an optional dot.case namespace prefix and optional snake_case suffix. DGP-50 --- .../specification-rules.test.ts.snap | 110 +++++++++++++++++- .../__tests__/specification-rules.test.ts | 44 +++++++ .../rest/2022-05-25/specification-rules.ts | 55 ++++++++- 3 files changed, 205 insertions(+), 4 deletions(-) diff --git a/src/rulesets/rest/2022-05-25/__tests__/__snapshots__/specification-rules.test.ts.snap b/src/rulesets/rest/2022-05-25/__tests__/__snapshots__/specification-rules.test.ts.snap index 59df3eb8..8fd0abab 100644 --- a/src/rulesets/rest/2022-05-25/__tests__/__snapshots__/specification-rules.test.ts.snap +++ b/src/rulesets/rest/2022-05-25/__tests__/__snapshots__/specification-rules.test.ts.snap @@ -379,7 +379,7 @@ exports[`specification rules fails when components are not PascalCase 1`] = ` }, "condition": undefined, "docsLink": "https://github.com/snyk/sweater-comb/blob/main/docs/standards/rest.md#referenced-entities", - "error": "Expected thingResourceResponse to be pascal case", + "error": "Expected thingResourceResponse to be pascal case in component thingResourceResponse", "exempted": false, "expected": undefined, "isMust": true, @@ -466,6 +466,114 @@ exports[`specification rules fails when components are not PascalCase 1`] = ` ] `; +exports[`specification rules passes when components are namespaced PascalCase 1`] = ` +[ + { + "change": { + "location": { + "conceptualLocation": {}, + "conceptualPath": [], + "jsonPath": "", + "kind": "specification", + }, + "value": { + "info": { + "title": "Empty", + "version": "0.0.0", + }, + "openapi": "3.1.0", + "x-snyk-api-stability": "wip", + }, + }, + "condition": undefined, + "docsLink": "https://github.com/snyk/sweater-comb/blob/main/docs/standards/rest.md#referenced-entities", + "error": undefined, + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "component names", + "passed": true, + "received": undefined, + "severity": 2, + "type": "requirement", + "where": "specification", + }, + { + "change": { + "location": { + "conceptualLocation": {}, + "conceptualPath": [], + "jsonPath": "", + "kind": "specification", + }, + "value": { + "info": { + "title": "Empty", + "version": "0.0.0", + }, + "openapi": "3.1.0", + "x-snyk-api-stability": "wip", + }, + }, + "condition": undefined, + "docsLink": "https://github.com/snyk/sweater-comb/blob/main/docs/standards/rest.md#tags", + "error": undefined, + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "open api version names", + "passed": true, + "received": undefined, + "severity": 2, + "type": "requirement", + "where": "specification", + }, + { + "change": { + "changeType": "changed", + "changed": { + "after": { + "info": { + "title": "Empty", + "version": "0.0.0", + }, + "openapi": "3.1.0", + "x-snyk-api-stability": "wip", + }, + "before": { + "info": { + "title": "Empty", + "version": "0.0.0", + }, + "openapi": "3.1.0", + }, + }, + "location": { + "conceptualLocation": {}, + "conceptualPath": [], + "jsonPath": "", + "kind": "specification", + }, + }, + "condition": undefined, + "docsLink": "https://github.com/snyk/sweater-comb/blob/main/docs/standards/rest.md#polymorphic-objects", + "error": undefined, + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "no mapping objects in discriminators", + "passed": true, + "received": undefined, + "severity": 2, + "type": "changed", + "where": "specification", + }, +] +`; + exports[`specification rules uncompiled specs passes when /openapi endpoint isn't specified 1`] = ` [ { diff --git a/src/rulesets/rest/2022-05-25/__tests__/specification-rules.test.ts b/src/rulesets/rest/2022-05-25/__tests__/specification-rules.test.ts index 410b6990..d8c61351 100644 --- a/src/rulesets/rest/2022-05-25/__tests__/specification-rules.test.ts +++ b/src/rulesets/rest/2022-05-25/__tests__/specification-rules.test.ts @@ -30,6 +30,11 @@ describe("specification rules", () => { description: "Response containing a single thing resource object", properties: {}, }, + "IO.SNYK.SOMETHING.ThingResourceResponse": { + type: "object", + description: "Response containing a single thing resource object", + properties: {}, + }, }, }, } as OpenAPIV3.Document; @@ -45,6 +50,45 @@ describe("specification rules", () => { expect(results).toMatchSnapshot(); }); + test("passes when components are namespaced PascalCase", async () => { + const afterJson = { + ...baseJson, + [stabilityKey]: "wip", + paths: {}, + components: { + schemas: { + "something.ThingResourceResponse": { + type: "object", + description: "Response containing a single thing resource object", + properties: {}, + }, + "io.snyk.something.ThingResourceResponse": { + type: "object", + description: "Response containing a single thing resource object", + properties: {}, + }, + }, + parameters: { + "io.snyk.something.SomeApiRequest.some_param": { + description: "Response containing a single thing resource object", + in: "path", + name: "some_param" + }, + }, + }, + } as OpenAPIV3.Document; + const ruleRunner = new RuleRunner([specificationRules]); + const ruleInputs = { + ...TestHelpers.createRuleInputs(baseJson, afterJson), + context, + }; + const results = await ruleRunner.runRulesWithFacts(ruleInputs); + + expect(results.length).toBeGreaterThan(0); + expect(results.every((result) => result.passed)).toBe(true); + expect(results).toMatchSnapshot(); + }); + test("fails if a discriminator is added with a mapping object", async () => { const afterJson = { ...baseJson, diff --git a/src/rulesets/rest/2022-05-25/specification-rules.ts b/src/rulesets/rest/2022-05-25/specification-rules.ts index 74cbddb3..fffe6dc2 100644 --- a/src/rulesets/rest/2022-05-25/specification-rules.ts +++ b/src/rulesets/rest/2022-05-25/specification-rules.ts @@ -1,8 +1,45 @@ import { RuleError, Ruleset, SpecificationRule } from "@useoptic/rulesets-base"; -import { pascalCase } from "change-case"; +import { dotCase, pascalCase, snakeCase } from "change-case"; import { links } from "../../../docs"; import { stabilityKey } from "./constants"; +interface componentName { + localName: string; + localProp?: string; + ns?: string; +} + +/** + * Decode the component name from a possibly namespace and property-qualified OpenAPI component name. + * + * @param name An OpenAPI component name + * @returns Tuple containing the local name (without namespace) and its namespace prefix (undefined if not namespaced). + */ +const decodeComponentName = (name: string): componentName => { + const parts = name.split("."); + if (parts.length > 1) { + const lastPart = parts[parts.length - 1]; + if (lastPart === snakeCase(lastPart)) { + // Component is derived from a Typespec model property, of the form namespace.ModelName.property_name. + // This is the case for shared parameter or header components, for example. + return { + localName: parts[parts.length - 2], + localProp: lastPart, + ns: parts.slice(0, parts.length - 2).join("."), + } + } else { + // Component is derived from a Typespec model, of the form namespace.ModelName.property_name. + // This is the case for schema components. + return { + localName: parts[parts.length - 1], + ns: parts.slice(0, parts.length - 1).join("."), + }; + } + } else { + return { localName: name }; + } +}; + const componentNameCase = new SpecificationRule({ name: "component names", docsLink: links.standards.referencedEntities, @@ -22,9 +59,21 @@ const componentNameCase = new SpecificationRule({ specification.raw.components?.[componentType] || {}, ); for (const componentName of componentNames) { - if (pascalCase(componentName) !== componentName) { + const {localName, localProp, ns} = decodeComponentName(componentName); + if (pascalCase(localName) !== localName) { + throw new RuleError({ + message: `Expected ${localName} to be pascal case in component ${componentName}`, + }); + } + if (localProp && snakeCase(localProp) !== localProp) { + throw new RuleError({ + message: `Expected ${localProp} to be snake case in component ${componentName}`, + }); + + } + if (ns && dotCase(ns) !== ns) { throw new RuleError({ - message: `Expected ${componentName} to be pascal case`, + message: `Expected ${ns} to be dot case in component ${componentName}`, }); } }