diff --git a/tools/spectral/ipa/__tests__/getMethodReturnsResponseSuffixedObject.test.js b/tools/spectral/ipa/__tests__/getMethodReturnsResponseSuffixedObject.test.js new file mode 100644 index 0000000000..17a91427f1 --- /dev/null +++ b/tools/spectral/ipa/__tests__/getMethodReturnsResponseSuffixedObject.test.js @@ -0,0 +1,263 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const componentSchemas = { + schemas: { + SchemaResponse: { + type: 'object', + }, + Schema: { + type: 'object', + }, + }, +}; + +testRule('xgen-IPA-104-get-method-returns-response-suffixed-object', [ + { + name: 'valid schema names names', + document: { + paths: { + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaResponse', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/SchemaResponse', + }, + }, + 'application/vnd.atlas.2025-01-01+json': { + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/SchemaResponse', + }, + }, + }, + }, + }, + 400: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + }, + }, + }, + }, + }, + '/resourceTwo/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + type: 'string', + }, + }, + }, + }, + }, + }, + '/resource/{id}/singleton': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/SchemaResponse', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [], + }, + { + name: 'invalid resources', + document: { + paths: { + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + 'application/vnd.atlas.2025-01-01+json': { + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/Schema', + }, + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}/singleton': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [ + { + code: 'xgen-IPA-104-get-method-returns-response-suffixed-object', + message: 'The request schema must reference a schema with a Response suffix. http://go/ipa/104', + path: [ + 'paths', + '/resource/{id}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2023-01-01+json', + ], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-104-get-method-returns-response-suffixed-object', + message: 'The request schema must reference a schema with a Response suffix. http://go/ipa/104', + path: [ + 'paths', + '/resource/{id}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-01-01+json', + ], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-104-get-method-returns-response-suffixed-object', + message: 'The request schema must reference a schema with a Response suffix. http://go/ipa/104', + path: [ + 'paths', + '/resource/{id}', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2025-01-01+json', + ], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-104-get-method-returns-response-suffixed-object', + message: 'The request schema must reference a schema with a Response suffix. http://go/ipa/104', + path: [ + 'paths', + '/resource/{id}/singleton', + 'get', + 'responses', + '200', + 'content', + 'application/vnd.atlas.2024-08-05+json', + ], + severity: DiagnosticSeverity.Warning, + }, + ], + }, + { + name: 'invalid resources with exceptions', + document: { + paths: { + '/resource/{id}': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2023-01-01+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-104-get-method-returns-response-suffixed-object': 'reason', + }, + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + 'application/vnd.atlas.2024-01-01+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-104-get-method-returns-response-suffixed-object': 'reason', + }, + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + 'application/vnd.atlas.2025-01-01+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-104-get-method-returns-response-suffixed-object': 'reason', + }, + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/Schema', + }, + }, + }, + }, + }, + }, + }, + }, + '/resource/{id}/singleton': { + get: { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-104-get-method-returns-response-suffixed-object': 'reason', + }, + schema: { + $ref: '#/components/schemas/Schema', + }, + }, + }, + }, + }, + }, + }, + }, + components: componentSchemas, + }, + errors: [], + }, +]); diff --git a/tools/spectral/ipa/rulesets/IPA-104.yaml b/tools/spectral/ipa/rulesets/IPA-104.yaml index 1326d1d9a4..dcf9e6461f 100644 --- a/tools/spectral/ipa/rulesets/IPA-104.yaml +++ b/tools/spectral/ipa/rulesets/IPA-104.yaml @@ -4,11 +4,12 @@ functions: - eachResourceHasGetMethod - getMethodReturnsSingleResource + - getMethodReturnsResponseSuffixedObject - getResponseCodeShouldBe200OK rules: xgen-IPA-104-resource-has-GET: - description: 'APIs must provide a get method for resources. http://go/ipa/104' + description: 'APIs must provide a Get method for resources. http://go/ipa/104' message: '{{error}} http://go/ipa/104' severity: warn given: '$.paths' @@ -16,7 +17,7 @@ rules: field: '@key' function: 'eachResourceHasGetMethod' xgen-IPA-104-get-method-returns-single-resource: - description: 'The purpose of the get method is to return data from a single resource. http://go/ipa/104' + description: 'The purpose of the Get method is to return data from a single resource. http://go/ipa/104' message: '{{error}} http://go/ipa/104' severity: warn given: '$.paths[*].get' @@ -29,3 +30,11 @@ rules: given: '$.paths[*].get' then: function: 'getResponseCodeShouldBe200OK' + xgen-IPA-104-get-method-returns-response-suffixed-object: + description: 'The Get method of a resource should return a "Response" suffixed object. http://go/ipa/104' + message: '{{error}} http://go/ipa/104' + severity: warn + given: '$.paths[*].get.responses[*].content' + then: + field: '@key' + function: 'getMethodReturnsResponseSuffixedObject' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 5d12512aee..82a7e342a2 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -28,11 +28,12 @@ For rule definitions, see [IPA-102.yaml](https://github.com/mongodb/openapi/blob For rule definitions, see [IPA-104.yaml](https://github.com/mongodb/openapi/blob/main/tools/spectral/ipa/rulesets/IPA-104.yaml). -| Rule Name | Description | Severity | -| ----------------------------------------------- | ----------------------------------------------------------------------------------------- | -------- | -| xgen-IPA-104-resource-has-GET | APIs must provide a get method for resources. http://go/ipa/104 | warn | -| xgen-IPA-104-get-method-returns-single-resource | The purpose of the get method is to return data from a single resource. http://go/ipa/104 | warn | -| xgen-IPA-104-get-method-response-code-is-200 | The Get method must return a 200 OK response. http://go/ipa/104 | warn | +| Rule Name | Description | Severity | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------ | -------- | +| xgen-IPA-104-resource-has-GET | APIs must provide a Get method for resources. http://go/ipa/104 | warn | +| xgen-IPA-104-get-method-returns-single-resource | The purpose of the Get method is to return data from a single resource. http://go/ipa/104 | warn | +| xgen-IPA-104-get-method-response-code-is-200 | The Get method must return a 200 OK response. http://go/ipa/104 | warn | +| xgen-IPA-104-get-method-returns-response-suffixed-object | The Get method of a resource should return a "Response" suffixed object. http://go/ipa/104 | warn | ### IPA-106 diff --git a/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsRequestSuffixedObject.js b/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsRequestSuffixedObject.js index edb96b029c..3ec368dd59 100644 --- a/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsRequestSuffixedObject.js +++ b/tools/spectral/ipa/rulesets/functions/createMethodRequestBodyIsRequestSuffixedObject.js @@ -2,6 +2,7 @@ import { hasException } from './utils/exceptions.js'; import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; import { isCustomMethodIdentifier } from './utils/resourceEvaluation.js'; import { resolveObject } from './utils/componentUtils.js'; +import { getSchemaRef } from './utils/methodUtils.js'; const RULE_NAME = 'xgen-IPA-106-create-method-request-body-is-request-suffixed-object'; const ERROR_MESSAGE_SCHEMA_NAME = 'The response body schema must reference a schema with a Request suffix.'; @@ -10,8 +11,9 @@ const ERROR_MESSAGE_SCHEMA_REF = 'The response body schema is defined inline and export default (input, _, { path, documentInventory }) => { const oas = documentInventory.unresolved; const resourcePath = path[1]; + const contentMediaType = path[path.length - 1]; - if (isCustomMethodIdentifier(resourcePath)) { + if (isCustomMethodIdentifier(resourcePath) || !contentMediaType.endsWith('json')) { return; } @@ -24,24 +26,13 @@ export default (input, _, { path, documentInventory }) => { if (contentPerMediaType.schema) { const schema = contentPerMediaType.schema; - if (schema.type === 'array' && schema.items) { - let schemaItems = schema.items; - if (!schemaItems.$ref) { - return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_REF); - } - if (!schemaItems.$ref.endsWith('Request')) { - return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_NAME); - } - } else { - if (!schema.$ref) { - return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_REF); - } - - if (!schema.$ref.endsWith('Request')) { - return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_NAME); - } + const schemaRef = getSchemaRef(schema); + if (!schemaRef) { + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_REF); + } + if (!schemaRef.endsWith('Request')) { + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_NAME); } - collectAdoption(path, RULE_NAME); } }; diff --git a/tools/spectral/ipa/rulesets/functions/getMethodReturnsResponseSuffixedObject.js b/tools/spectral/ipa/rulesets/functions/getMethodReturnsResponseSuffixedObject.js new file mode 100644 index 0000000000..a05fd490ef --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/getMethodReturnsResponseSuffixedObject.js @@ -0,0 +1,48 @@ +import { + isSingleResourceIdentifier, + isSingletonResource, + getResourcePathItems, + isResourceCollectionIdentifier, +} from './utils/resourceEvaluation.js'; +import { resolveObject } from './utils/componentUtils.js'; +import { hasException } from './utils/exceptions.js'; +import { collectAdoption, collectAndReturnViolation, collectException } from './utils/collectionUtils.js'; +import { getSchemaRef } from './utils/methodUtils.js'; + +const RULE_NAME = 'xgen-IPA-104-get-method-returns-response-suffixed-object'; +const ERROR_MESSAGE_SCHEMA_NAME = 'The request schema must reference a schema with a Response suffix.'; +const ERROR_MESSAGE_SCHEMA_REF = 'The response body schema is defined inline and must reference a predefined schema.'; + +export default (input, _, { path, documentInventory }) => { + const resourcePath = path[1]; + const responseCode = path[4]; + const contentMediaType = path[path.length - 1]; + const oas = documentInventory.unresolved; + const resourcePaths = getResourcePathItems(resourcePath, oas.paths); + + if ( + responseCode.startsWith('2') && + contentMediaType.endsWith('json') && + (isSingleResourceIdentifier(resourcePath) || + (isResourceCollectionIdentifier(resourcePath) && isSingletonResource(resourcePaths))) + ) { + const contentPerMediaType = resolveObject(oas, path); + + if (hasException(contentPerMediaType, RULE_NAME)) { + collectException(contentPerMediaType, RULE_NAME, path); + return; + } + + if (contentPerMediaType.schema) { + const schema = contentPerMediaType.schema; + const schemaRef = getSchemaRef(schema); + if (!schemaRef) { + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_REF); + } + if (!schemaRef.endsWith('Response')) { + return collectAndReturnViolation(path, RULE_NAME, ERROR_MESSAGE_SCHEMA_NAME); + } + collectAdoption(path, RULE_NAME); + } + } +}; diff --git a/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js b/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js index 8781360966..0f97df3f52 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js +++ b/tools/spectral/ipa/rulesets/functions/utils/methodUtils.js @@ -29,3 +29,16 @@ export function getAllSuccessfulResponseSchemas(operationObject) { }); return result; } + +/** + * Gets the schema reference for a schema object. If the schema does not have a reference, undefined is returned. + * + * @param {object} schema the unresolved schema object + * @returns {string} the schema ref + */ +export function getSchemaRef(schema) { + if (schema.type === 'array' && schema.items) { + return schema.items.$ref; + } + return schema.$ref; +}