Skip to content

Commit

Permalink
Merge pull request #683 from snyk/feat/support-namespaced-component-n…
Browse files Browse the repository at this point in the history
…ames

feat: allow namespaced component names
  • Loading branch information
cmars authored Feb 3, 2025
2 parents 112a79a + c22f0ff commit e0c65ba
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 7 deletions.
33 changes: 30 additions & 3 deletions docs/standards/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,38 @@ Resource collection names, parameters and path variables must use **snake case**

Because these variables are represented in URLs, uppercase letters may cause problems on some client platforms; RFCs recommend that URLs are treated as case-sensitive, but it is a "should", not a "must". Dashes might cause problems for some code generators, ruling out kebab case.

### Referenced Entities
### Component naming

Entities referenced in other documents (using `$ref`) must use **pascal case** names.
[OpenAPI components](https://spec.openapis.org/oas/v3.1.0#components-object) must use **pascal case** names.

Entities will be commonly represented as types or classes when generating code. Pascal case names are conventionally used for such symbols in most targeted languages.
When generating OpenAPI with [TypeSpec](https://typespec.io/)'s [OpenAPI emitter](https://typespec.io/docs/emitters/openapi3/reference/emitter/),
these names may be prefixed by a dot-separated lower-case namespace. Components derived from a model property may be suffixed by a snake-cased property name.

To illustrate,

```typespec
namespace io.snyk.api.common;
model SnykApiRequest {
@header("snyk-request-id")
@format("uuid")
request_id?: string;
}
```

might produce an OpenAPI component:

```yaml
components:
parameters:
"io.snyk.api.common.SnykApiRequest.request_id":
name: snyk-request-id
in: header
required: false
schema:
type: string
format: uuid
```

### Schema properties

Expand Down
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 notSnakey to be pascal case in component io.snyk.something.ThingResourceResponse.notSnakey",
"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
47 changes: 47 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 @@ -23,13 +23,22 @@ describe("specification rules", () => {
name: "thingId",
in: "path",
},
"io.snyk.something.ThingResourceResponse.notSnakey": {
name: "not-snakey",
in: "header",
},
},
schemas: {
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: {},
},
},
},
} as OpenAPIV3.Document;
Expand All @@ -45,6 +54,44 @@ 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": {
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.
// 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 e0c65ba

Please sign in to comment.