Skip to content

Commit

Permalink
feat: allow namespaced component names
Browse files Browse the repository at this point in the history
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
  • Loading branch information
cmars committed Jan 30, 2025
1 parent 112a79a commit e436220
Show file tree
Hide file tree
Showing 3 changed files with 205 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`] = `
[
{
Expand Down
44 changes: 44 additions & 0 deletions src/rulesets/rest/2022-05-25/__tests__/specification-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
55 changes: 52 additions & 3 deletions src/rulesets/rest/2022-05-25/specification-rules.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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}`,
});
}
}
Expand Down

0 comments on commit e436220

Please sign in to comment.