mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
Feature: Add component-name-unique rule (#1134)
This commit is contained in:
109
docs/rules/component-name-unique.md
Normal file
109
docs/rules/component-name-unique.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# component-name-unique
|
||||
|
||||
Verifies component names are unique.
|
||||
|
||||
|OAS|Compatibility|
|
||||
|---|--|
|
||||
|2.0|❌|
|
||||
|3.0|✅|
|
||||
|3.1|✅|
|
||||
|
||||
|
||||
## API design principles
|
||||
|
||||
When generating code based on an OpenAPI definition, there are various different problems when component names are not
|
||||
unique through the whole spec.
|
||||
|
||||
- schema: The code generator creates a class for each schema.
|
||||
If they are not uniquely named, the generator appends numbers. These numbers are non-deterministic.
|
||||
By adding a new schema with the same component name it could change the name (appended number) of another one.
|
||||
- parameter: The code generator creates a class for each parameter.
|
||||
If they are not uniquely named, the generator appends numbers. These numbers are non-deterministic.
|
||||
By adding a new parameter with the same component name it could change the name (appended number) of another one.
|
||||
- response: The code generator tends to reuse the first one and drops the other ones with the same component name.
|
||||
- requestBody: The code generator tends to reuse the first one and drops the other ones with the same component name.
|
||||
|
||||
This clearly is not optimal. Having unique component names prevents these problems.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option |Type| Description |
|
||||
|---------------|---|------------------------------------------------------------------------------------------|
|
||||
| severity |string| Possible values: `off`, `warn`, `error`. Default `off` (in `recommended` configuration). |
|
||||
| schemas |string| Possible values: `off`, `warn`, `error`. Default: not set. |
|
||||
| parameters |string| Possible values: `off`, `warn`, `error`. Default: not set. |
|
||||
| responses |string| Possible values: `off`, `warn`, `error`. Default: not set. |
|
||||
| requestBodies |string| Possible values: `off`, `warn`, `error`. Default: not set. |
|
||||
|
||||
An example configuration:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
component-name-unique:
|
||||
schemas: error
|
||||
parameters: off
|
||||
responses: warn
|
||||
requestBodies: warn
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
Given this configuration:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
component-name-unique: error
|
||||
```
|
||||
|
||||
### Example of **incorrect** schema files
|
||||
|
||||
file1.yaml:
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
FooSchema:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
$ref: './file2.yaml#/components/schemas/FooSchema'
|
||||
```
|
||||
|
||||
file2.yaml:
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
FooSchema:
|
||||
type: object
|
||||
properties:
|
||||
otherField:
|
||||
type: string
|
||||
```
|
||||
|
||||
### Example of **correct** schema files
|
||||
|
||||
file1.yaml:
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
FooSchema:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
$ref: './file2.yaml#/components/schemas/AnotherFooSchema'
|
||||
```
|
||||
|
||||
file2.yaml:
|
||||
```yaml
|
||||
components:
|
||||
schemas:
|
||||
AnotherFooSchema:
|
||||
type: object
|
||||
properties:
|
||||
otherField:
|
||||
type: string
|
||||
```
|
||||
|
||||
## Relates rules
|
||||
|
||||
- [no-unused-components](./no-unused-components.md)
|
||||
@@ -38,6 +38,7 @@ Object {
|
||||
"rules": Object {
|
||||
"assertions": "warn",
|
||||
"boolean-parameter-prefixes": "error",
|
||||
"component-name-unique": "off",
|
||||
"info-contact": "off",
|
||||
"info-license": "warn",
|
||||
"info-license-url": "warn",
|
||||
@@ -128,6 +129,7 @@ Object {
|
||||
},
|
||||
],
|
||||
"boolean-parameter-prefixes": "error",
|
||||
"component-name-unique": "off",
|
||||
"info-contact": "off",
|
||||
"info-license": "warn",
|
||||
"info-license-url": "warn",
|
||||
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
'no-invalid-parameter-examples': 'error',
|
||||
'scalar-property-missing-example': 'error',
|
||||
'spec-strict-refs': 'error',
|
||||
'component-name-unique': 'error',
|
||||
},
|
||||
oas3_0Rules: {
|
||||
'no-invalid-media-type-examples': 'error',
|
||||
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
'paths-kebab-case': 'off',
|
||||
spec: 'error',
|
||||
'spec-strict-refs': 'off',
|
||||
'component-name-unique': 'off',
|
||||
},
|
||||
oas3_0Rules: {
|
||||
'no-invalid-media-type-examples': {
|
||||
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
'paths-kebab-case': 'off',
|
||||
spec: 'error',
|
||||
'spec-strict-refs': 'off',
|
||||
'component-name-unique': 'off',
|
||||
},
|
||||
oas3_0Rules: {
|
||||
'no-invalid-media-type-examples': {
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
import { outdent } from 'outdent';
|
||||
import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils';
|
||||
import { lintDocumentForTest } from './utils/lint-document-for-test';
|
||||
|
||||
describe('Oas3 component-name-unique', () => {
|
||||
describe('schema', () => {
|
||||
it('should report on multiple schemas with same name', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
schemas:
|
||||
SomeSchema:
|
||||
type: object
|
||||
Test:
|
||||
type: object
|
||||
properties:
|
||||
there:
|
||||
$ref: '/test.yaml#/components/schemas/SomeSchema'
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
schemas:
|
||||
SomeSchema:
|
||||
type: object
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'schemas/SomeSchema' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/schemas/SomeSchema
|
||||
- /test.yaml#/components/schemas/SomeSchema",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should report on multiple schemas with same name - filename', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
schemas:
|
||||
SomeSchema:
|
||||
type: object
|
||||
Test:
|
||||
type: object
|
||||
properties:
|
||||
there:
|
||||
$ref: '/SomeSchema.yaml'
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/SomeSchema.yaml',
|
||||
body: outdent`
|
||||
type: object
|
||||
properties:
|
||||
test:
|
||||
type: number
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'schemas/SomeSchema' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/schemas/SomeSchema
|
||||
- /SomeSchema.yaml",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not report on multiple schemas with different names', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
schemas:
|
||||
SomeSchema:
|
||||
type: object
|
||||
Test:
|
||||
type: object
|
||||
properties:
|
||||
there:
|
||||
$ref: '/test.yaml#/components/schemas/OtherSchema'
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
schemas:
|
||||
OtherSchema:
|
||||
type: object
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter', () => {
|
||||
it('should report if multiple parameters have same component name', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ParameterOne'
|
||||
- $ref: '/test.yaml#/components/parameters/ParameterOne'
|
||||
components:
|
||||
parameters:
|
||||
ParameterOne:
|
||||
name: parameterOne
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
parameters:
|
||||
ParameterOne:
|
||||
name: oneParameter
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'parameters/ParameterOne' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/parameters/ParameterOne
|
||||
- /test.yaml#/components/parameters/ParameterOne",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should report on multiple parameters with same component name - filename', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ParameterOne'
|
||||
- $ref: '/ParameterOne.yaml'
|
||||
components:
|
||||
parameters:
|
||||
ParameterOne:
|
||||
name: parameterOne
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/ParameterOne.yaml',
|
||||
body: outdent`
|
||||
name: oneParameter
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'parameters/ParameterOne' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/parameters/ParameterOne
|
||||
- /ParameterOne.yaml",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not report on multiple parameters with different component names', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ParameterOne'
|
||||
- $ref: '/test.yaml#/components/parameters/OneParameter'
|
||||
components:
|
||||
parameters:
|
||||
ParameterOne:
|
||||
name: parameterOne
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
parameters:
|
||||
OneParameter:
|
||||
name: oneParameter
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response', () => {
|
||||
it('should report if multiple responses have same component name', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessResponse'
|
||||
/test2:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
$ref: '/test.yaml#/components/responses/SuccessResponse'
|
||||
components:
|
||||
responses:
|
||||
SuccessResponse:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
responses:
|
||||
SuccessResponse:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'responses/SuccessResponse' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/responses/SuccessResponse
|
||||
- /test.yaml#/components/responses/SuccessResponse",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should report on multiple responses with same component name - filename', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/SuccessResponse'
|
||||
/test2:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
$ref: '/SuccessResponse.yaml'
|
||||
components:
|
||||
responses:
|
||||
SuccessResponse:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/SuccessResponse.yaml',
|
||||
body: outdent`
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'responses/SuccessResponse' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/responses/SuccessResponse
|
||||
- /SuccessResponse.yaml",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not report on multiple responses with different component names', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
$ref: '#/components/responses/TestSuccessResponse'
|
||||
/test2:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
$ref: '/test.yaml#/components/responses/Test2SuccessResponse'
|
||||
components:
|
||||
responses:
|
||||
TestSuccessResponse:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
openapi: 3.0.0
|
||||
components:
|
||||
responses:
|
||||
Test2SuccessResponse:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request-body', () => {
|
||||
it('should report if multiple request bodies have same component name', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '#/components/requestBodies/MyRequestBody'
|
||||
/test2:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '/test.yaml#/components/requestBodies/MyRequestBody'
|
||||
components:
|
||||
requestBodies:
|
||||
MyRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
components:
|
||||
requestBodies:
|
||||
MyRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/requestBodies/MyRequestBody
|
||||
- /test.yaml#/components/requestBodies/MyRequestBody",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should report on multiple responses with same component name - filename', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '#/components/requestBodies/MyRequestBody'
|
||||
/test2:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '/MyRequestBody.yaml'
|
||||
components:
|
||||
requestBodies:
|
||||
MyRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/MyRequestBody.yaml',
|
||||
body: outdent`
|
||||
components:
|
||||
requestBodies:
|
||||
MyRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/requestBodies/MyRequestBody
|
||||
- /MyRequestBody.yaml",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not report on multiple responses with different component names', async () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '#/components/requestBodies/TestRequestBody'
|
||||
/test2:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '/test.yaml#/components/requestBodies/Test2RequestBody'
|
||||
components:
|
||||
requestBodies:
|
||||
TestRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
components:
|
||||
requestBodies:
|
||||
Test2RequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot('Array []');
|
||||
});
|
||||
});
|
||||
|
||||
describe('different severities', () => {
|
||||
const document = parseYamlToDocument(
|
||||
outdent`
|
||||
openapi: 3.0.0
|
||||
paths:
|
||||
/test:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '#/components/requestBodies/MyRequestBody'
|
||||
/test2:
|
||||
post:
|
||||
requestBody:
|
||||
$ref: '/test.yaml#/components/requestBodies/MyRequestBody'
|
||||
components:
|
||||
schemas:
|
||||
SomeSchema:
|
||||
type: object
|
||||
Test:
|
||||
type: object
|
||||
properties:
|
||||
there:
|
||||
$ref: '/test.yaml#/components/schemas/SomeSchema'
|
||||
requestBodies:
|
||||
MyRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
'/foobar.yaml'
|
||||
);
|
||||
const additionalDocuments = [
|
||||
{
|
||||
absoluteRef: '/test.yaml',
|
||||
body: outdent`
|
||||
components:
|
||||
schemas:
|
||||
SomeSchema:
|
||||
type: object
|
||||
requestBodies:
|
||||
MyRequestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
it('should report both schema and request body', async () => {
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': 'error' },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/requestBodies/MyRequestBody
|
||||
- /test.yaml#/components/requestBodies/MyRequestBody",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'schemas/SomeSchema' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/schemas/SomeSchema
|
||||
- /test.yaml#/components/schemas/SomeSchema",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('should not report if severity is off for specific component type', async () => {
|
||||
const results = await lintDocumentForTest(
|
||||
{ 'component-name-unique': { severity: 'error', schemas: 'off' } },
|
||||
document,
|
||||
additionalDocuments
|
||||
);
|
||||
|
||||
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"location": Array [
|
||||
Object {
|
||||
"pointer": "#/",
|
||||
"reportOnKey": false,
|
||||
"source": "/foobar.yaml",
|
||||
},
|
||||
],
|
||||
"message": "Component 'requestBodies/MyRequestBody' is not unique. It is defined at:
|
||||
- /foobar.yaml#/components/requestBodies/MyRequestBody
|
||||
- /test.yaml#/components/requestBodies/MyRequestBody",
|
||||
"ruleId": "component-name-unique",
|
||||
"severity": "error",
|
||||
"suggest": Array [],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseResolver, Document } from '../../../../resolve';
|
||||
import { makeConfig, parseYamlToDocument } from '../../../../../__tests__/utils';
|
||||
import { lintDocument } from '../../../../lint';
|
||||
import { RuleConfig } from '../../../../config';
|
||||
|
||||
export async function lintDocumentForTest(
|
||||
rules: Record<string, RuleConfig>,
|
||||
document: Document,
|
||||
additionalDocuments: { absoluteRef: string; body: string }[]
|
||||
) {
|
||||
const baseResolver = new BaseResolver();
|
||||
additionalDocuments.forEach((item) =>
|
||||
baseResolver.cache.set(
|
||||
item.absoluteRef,
|
||||
Promise.resolve(parseYamlToDocument(item.body, item.absoluteRef))
|
||||
)
|
||||
);
|
||||
return await lintDocument({
|
||||
externalRefResolver: baseResolver,
|
||||
document,
|
||||
config: await makeConfig(rules),
|
||||
});
|
||||
}
|
||||
158
packages/core/src/rules/oas3/component-name-unique.ts
Normal file
158
packages/core/src/rules/oas3/component-name-unique.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Problem, UserContext } from '../../walk';
|
||||
import { Oas2Rule, Oas3Rule, Oas3Visitor } from '../../visitors';
|
||||
import {
|
||||
Oas3Definition,
|
||||
Oas3Parameter,
|
||||
Oas3RequestBody,
|
||||
Oas3Response,
|
||||
Oas3Schema,
|
||||
OasRef,
|
||||
} from '../../typings/openapi';
|
||||
|
||||
const TYPE_NAME_SCHEMA = 'Schema';
|
||||
const TYPE_NAME_PARAMETER = 'Parameter';
|
||||
const TYPE_NAME_RESPONSE = 'Response';
|
||||
const TYPE_NAME_REQUEST_BODY = 'RequestBody';
|
||||
|
||||
const TYPE_NAME_TO_OPTION_COMPONENT_NAME: { [key: string]: string } = {
|
||||
[TYPE_NAME_SCHEMA]: 'schemas',
|
||||
[TYPE_NAME_PARAMETER]: 'parameters',
|
||||
[TYPE_NAME_RESPONSE]: 'responses',
|
||||
[TYPE_NAME_REQUEST_BODY]: 'requestBodies',
|
||||
};
|
||||
|
||||
export const ComponentNameUnique: Oas3Rule | Oas2Rule = (options) => {
|
||||
const components = new Map<string, Set<string>>();
|
||||
|
||||
const typeNames: string[] = [];
|
||||
if (options.schemas !== 'off') {
|
||||
typeNames.push(TYPE_NAME_SCHEMA);
|
||||
}
|
||||
if (options.parameters !== 'off') {
|
||||
typeNames.push(TYPE_NAME_PARAMETER);
|
||||
}
|
||||
if (options.responses !== 'off') {
|
||||
typeNames.push(TYPE_NAME_RESPONSE);
|
||||
}
|
||||
if (options.requestBodies !== 'off') {
|
||||
typeNames.push(TYPE_NAME_REQUEST_BODY);
|
||||
}
|
||||
|
||||
const rule: Oas3Visitor = {
|
||||
ref: {
|
||||
leave(ref: OasRef, { type, resolve }: UserContext) {
|
||||
const typeName = type.name;
|
||||
if (typeNames.includes(typeName)) {
|
||||
const resolvedRef = resolve(ref);
|
||||
if (!resolvedRef.location) return;
|
||||
|
||||
addComponentFromAbsoluteLocation(
|
||||
typeName,
|
||||
resolvedRef.location.absolutePointer.toString()
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
Root: {
|
||||
leave(root: Oas3Definition, ctx: UserContext) {
|
||||
components.forEach((value, key, _) => {
|
||||
if (value.size > 1) {
|
||||
const component = getComponentFromKey(key);
|
||||
const optionComponentName = getOptionComponentNameForTypeName(component.typeName);
|
||||
const definitions = Array.from(value)
|
||||
.map((v) => `- ${v}`)
|
||||
.join('\n');
|
||||
|
||||
const problem: Problem = {
|
||||
message: `Component '${optionComponentName}/${component.componentName}' is not unique. It is defined at:\n${definitions}`,
|
||||
};
|
||||
|
||||
const componentSeverity = optionComponentName ? options[optionComponentName] : null;
|
||||
if (componentSeverity) {
|
||||
problem.forceSeverity = componentSeverity;
|
||||
}
|
||||
ctx.report(problem);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (options.schemas != 'off') {
|
||||
rule.NamedSchemas = {
|
||||
Schema(_: Oas3Schema, { location }: UserContext) {
|
||||
addComponentFromAbsoluteLocation(TYPE_NAME_SCHEMA, location.absolutePointer.toString());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options.responses != 'off') {
|
||||
rule.NamedResponses = {
|
||||
Response(_: Oas3Response, { location }: UserContext) {
|
||||
addComponentFromAbsoluteLocation(TYPE_NAME_RESPONSE, location.absolutePointer.toString());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options.parameters != 'off') {
|
||||
rule.NamedParameters = {
|
||||
Parameter(_: Oas3Parameter, { location }: UserContext) {
|
||||
addComponentFromAbsoluteLocation(TYPE_NAME_PARAMETER, location.absolutePointer.toString());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options.requestBodies != 'off') {
|
||||
rule.NamedRequestBodies = {
|
||||
RequestBody(_: Oas3RequestBody, { location }: UserContext) {
|
||||
addComponentFromAbsoluteLocation(
|
||||
TYPE_NAME_REQUEST_BODY,
|
||||
location.absolutePointer.toString()
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return rule;
|
||||
|
||||
function getComponentNameFromAbsoluteLocation(absoluteLocation: string): string {
|
||||
const componentName = absoluteLocation.split('/').slice(-1)[0];
|
||||
if (
|
||||
componentName.endsWith('.yml') ||
|
||||
componentName.endsWith('.yaml') ||
|
||||
componentName.endsWith('.json')
|
||||
) {
|
||||
return componentName.slice(0, componentName.lastIndexOf('.'));
|
||||
}
|
||||
return componentName;
|
||||
}
|
||||
|
||||
function addFoundComponent(
|
||||
typeName: string,
|
||||
componentName: string,
|
||||
absoluteLocation: string
|
||||
): void {
|
||||
const key = getKeyForComponent(typeName, componentName);
|
||||
const locations = components.get(key) ?? new Set();
|
||||
locations.add(absoluteLocation);
|
||||
components.set(key, locations);
|
||||
}
|
||||
|
||||
function addComponentFromAbsoluteLocation(typeName: string, absoluteLocation: string): void {
|
||||
const componentName = getComponentNameFromAbsoluteLocation(absoluteLocation);
|
||||
addFoundComponent(typeName, componentName, absoluteLocation);
|
||||
}
|
||||
};
|
||||
|
||||
function getOptionComponentNameForTypeName(typeName: string): string | null {
|
||||
return TYPE_NAME_TO_OPTION_COMPONENT_NAME[typeName] ?? null;
|
||||
}
|
||||
|
||||
function getKeyForComponent(typeName: string, componentName: string): string {
|
||||
return `${typeName}/${componentName}`;
|
||||
}
|
||||
|
||||
function getComponentFromKey(key: string): { typeName: string; componentName: string } {
|
||||
const [typeName, componentName] = key.split('/');
|
||||
return { typeName, componentName };
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import { SpecComponentsInvalidMapName } from './spec-components-invalid-map-name
|
||||
import { Operation4xxProblemDetailsRfc7807 } from './operation-4xx-problem-details-rfc7807';
|
||||
import { RequiredStringPropertyMissingMinLength } from '../common/required-string-property-missing-min-length';
|
||||
import { SpecStrictRefs } from '../common/spec-strict-refs';
|
||||
import { ComponentNameUnique } from './component-name-unique';
|
||||
|
||||
export const rules = {
|
||||
spec: OasSpec,
|
||||
@@ -106,6 +107,7 @@ export const rules = {
|
||||
'spec-components-invalid-map-name': SpecComponentsInvalidMapName,
|
||||
'required-string-property-missing-min-length': RequiredStringPropertyMissingMinLength,
|
||||
'spec-strict-refs': SpecStrictRefs,
|
||||
'component-name-unique': ComponentNameUnique,
|
||||
} as Oas3RuleSet;
|
||||
|
||||
export const preprocessors = {};
|
||||
|
||||
@@ -67,6 +67,7 @@ const builtInRulesList = [
|
||||
'spec-components-invalid-map-name',
|
||||
'required-string-property-missing-min-length',
|
||||
'spec-strict-refs',
|
||||
'component-name-unique',
|
||||
];
|
||||
const nodeTypesList = [
|
||||
'any',
|
||||
|
||||
Reference in New Issue
Block a user