diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d637f64..e79e3bb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,9 +25,9 @@ jobs: python-version: ["3.9", "3.10"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 85d9d15..1e8da15 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,4 @@ -name: Docker Hub +name: Docker concurrency: cancel-in-progress: true @@ -9,43 +9,47 @@ on: branches: - 'develop' - 'main' - tags: + tags: - '*.*.*' - + +permissions: + contents: read + packages: write + jobs: build: runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 + name: Checkout + uses: actions/checkout@v6 - - name: Login to Docker Hub - uses: docker/login-action@v1 + name: Login to GitHub Container Registry + uses: docker/login-action@v4 with: - username: fabfuel - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v4 - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: true - tags: fabfuel/api-deploy:${{ github.ref_name }} + tags: ghcr.io/packmatic/api-deploy:${{ github.ref_name }} - name: "Build and push (tag: latest)" if: github.ref == 'refs/heads/develop' - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64 push: true - tags: fabfuel/api-deploy:latest + tags: ghcr.io/packmatic/api-deploy:latest diff --git a/README.md b/README.md index da726d2..ee271b7 100644 --- a/README.md +++ b/README.md @@ -1 +1,109 @@ -# API Deploy \ No newline at end of file +# API Deploy + +Compile an OpenAPI spec into an Amazon API Gateway definition and deploy it. + +Packmatic fork of [fabfuel/api-deploy](https://github.com/fabfuel/api-deploy). Pushing to +`develop` publishes `ghcr.io/packmatic/api-deploy:develop`, which `packaging`'s API Gateway +deploy uses. + +## Install + +```bash +docker run --rm -v "$PWD":/workspace -w /workspace \ + ghcr.io/packmatic/api-deploy:develop \ + api compile +``` + +## Usage + +```bash +api compile # compile only +api deploy +``` + +## Configuration + +```yaml +gateway: + integrationHost: 'https://${stageVariables.host}' + connectionId: '${stageVariables.connectionId}' + removeScopes: true # strip OAuth scopes; API Gateway rejects them + removeDescriptions: true # strip `description` from components/schemas + removeExamples: true # strip `example` / `examples` +flatten: + dedupExternalRefs: true # share resolved external $refs instead of copying them +headers: + request: [Authorization, Content-Type] +cors: + origin: '*' +static: + files: [api.v1.yml] +strict: + enabled: true + overwriteRequired: true + blocklist: [_links, _meta] +generator: + languages: [typescript] + output: src/openapi/types +``` + +`removeExamples` and `dedupExternalRefs` both default to `false`, so existing configs +compile byte-for-byte as before. + +### `flatten.dedupExternalRefs` + +API Gateway's `put-rest-api` / `import-rest-api` body limit is +[6 MiB](https://docs.aws.amazon.com/apigateway/latest/api/API_PutRestApi.html#API_PutRestApi_RequestBody). +By default every external `$ref` is resolved into a **full inline copy** at each use site, and +YAML anchors are disabled, so a spec that shares a handful of error responses across a few +hundred endpoints inflates enormously — Packmatic's spec inlined ~1,100 copies of the same +six error schemas. + +With `dedupExternalRefs: true`, each resolved external schema is registered once under +`components/schemas` and every use site becomes an internal `$ref`. API Gateway resolves +internal `#/components/schemas/*` refs, so the result imports unchanged. + +Scope: refs in a `schema:` position, and the payload schema of any resolved object carrying +`content` (responses, request bodies). The response *wrapper* stays inline — only the payload +schema is shared. Refs nested inside `properties` are still inlined, as are parameter refs, +which API Gateway requires inline. + +Component names derive from the ref filename, forced to be alphanumeric and to start with a +letter (`responses/500-server-error.yml` → `Response500ServerError`), and are uniquified +against existing components. Schemas with identical bodies collapse into one component even +when reached through differently spelled refs. + +Effect on Packmatic's packaging spec: **6,364,377 B → 4,214,757 B**, or 3,915,048 B with +`removeExamples` as well. API Gateway model count drops from ~1,214 to ~128, since identical +models are shared rather than duplicated per use site; per-method validation is unchanged. + +### `gateway.removeExamples` + +Strips `example` and `examples`. A schema *property* literally named `example` is preserved — +only keyword positions are stripped — and `x-amazon-apigateway-integration` blocks are left +untouched. Note this runs before the CORS processor, so CORS response-header examples it +generates afterwards remain. + +Do not enable it in a config that also has a `generator` section: the TypeScript generator +emits `Example:` JSDoc from these values, so the generated types would lose those comments. +Keep it in the API-Gateway-only config. + +## Releasing + +Merging to `develop` builds and pushes `ghcr.io/packmatic/api-deploy:develop` (and `:latest`). +Pushing a `x.y.z` tag publishes that tag too. Nothing to run by hand. + +## Development + +```bash +pip install . -r requirements-test.txt +pytest +flake8 api_deploy +``` + +The functional tests resolve external `$ref`s against the live schemas at +`api.packmatic.io` / `api-staging.packmatic.io`, which are served from the +[api-types](https://github.com/Packmatic/api-types) repo. When api-types changes a shared +schema, `tests/openapi/*_target.yml` goes stale and must be regenerated — api-types 1.11.0 +widened the `urn.yml` pattern to support two ids and added fields to the error responses, +which is why those fixtures were refreshed. \ No newline at end of file diff --git a/api_deploy/__init__.py b/api_deploy/__init__.py index c4d9146..518855e 100644 --- a/api_deploy/__init__.py +++ b/api_deploy/__init__.py @@ -1 +1 @@ -VERSION = '0.17.0' +VERSION = '0.18.0' diff --git a/api_deploy/config.py b/api_deploy/config.py index af5a874..47ea63d 100644 --- a/api_deploy/config.py +++ b/api_deploy/config.py @@ -14,6 +14,7 @@ def __init__(self, config_file: ConfigFile, file_path) -> None: 'response': [], }, 'strict': {}, + 'flatten': {}, 'gateway': {}, 'cors': {}, 'static': { @@ -32,6 +33,10 @@ def __init__(self, config_file: ConfigFile, file_path) -> None: default_config['gateway'].setdefault('connection_id', config_file.get('gateway', {}).get('connectionId', '')) default_config['gateway'].setdefault('remove_scopes', config_file.get('gateway', {}).get('removeScopes', False)) default_config['gateway'].setdefault('remove_descriptions', config_file.get('gateway', {}).get('removeDescriptions', False)) + default_config['gateway'].setdefault('remove_examples', config_file.get('gateway', {}).get('removeExamples', False)) + + default_config['flatten'].setdefault('dedup_external_refs', + config_file.get('flatten', {}).get('dedupExternalRefs', False)) default_config['cors'].setdefault('allow_origin', config_file.get('cors', {}).get('origin', '*')) diff --git a/api_deploy/converters.py b/api_deploy/converters.py index 141c909..eb04fab 100644 --- a/api_deploy/converters.py +++ b/api_deploy/converters.py @@ -1,4 +1,7 @@ +import re from copy import deepcopy + +import yaml from mergedeep import merge from requests import get, HTTPError @@ -18,7 +21,7 @@ def __init__(self) -> None: def default(cls, config: Config): default_manager = cls() default_manager.register(StaticFileProcessor(config, **config['static'])) - default_manager.register(FlattenProcessor(config)) + default_manager.register(FlattenProcessor(config, **config['flatten'])) default_manager.register(PassthroughProcessor(config, **config['headers'])) default_manager.register(StrictProcessor(config, **config['strict'])) default_manager.register(ApiGatewayProcessor(config, **config['gateway'])) @@ -40,11 +43,14 @@ def process(self, original_schema: Schema) -> Schema: class FlattenProcessor(AbstractProcessor): - def __init__(self, config: Config, **kwargs) -> None: + def __init__(self, config: Config, dedup_external_refs=False, **kwargs) -> None: super().__init__(config) self.base_url = None self.used_refs = set() self.external_schemas = {} + self.dedup_external_refs = dedup_external_refs + self.hoisted_names_by_fingerprint = {} + self.schemas_to_register = {} def process(self, source: Schema) -> Schema: target = deepcopy(source) @@ -63,6 +69,12 @@ def process(self, source: Schema) -> Schema: self.replace_refs_dict(target['paths'], target) self.replace_refs_dict(target['paths'], target) + # Register hoisted schemas, resolving refs inside them until nothing new is hoisted + while self.schemas_to_register: + newly_hoisted, self.schemas_to_register = self.schemas_to_register, {} + target['components']['schemas'].update(newly_hoisted) + self.replace_refs_dict(target['components']['schemas'], target) + try: del target['components']['parameters'] except KeyError: @@ -78,6 +90,8 @@ def process(self, source: Schema) -> Schema: def replace_refs_dict(self, node, schema, replace_ref=True, enforce_replace=False): if self.is_ref(node) and (replace_ref or enforce_replace or self.is_external_ref(node)): + if self.dedup_external_refs and self.is_external_ref(node) and not enforce_replace: + return self.hoist_external_ref(node, schema, is_schema_position=not replace_ref) return self.lookup_ref(node, schema) elif self.is_ref(node): self.used_refs.add(self.get_ref_model_name(node['$ref'])) @@ -106,6 +120,65 @@ def get_to_used_schemas(schemas: dict, used_refs: set): used_schemas[model] = schemas[model] return used_schemas + def hoist_external_ref(self, ref: dict, schema: Schema, is_schema_position: bool): + """Resolve an external $ref once into components/schemas and reference it from every use site. + + API Gateway resolves internal $refs, so this collapses thousands of duplicated inline copies. + """ + ref_url = ref['$ref'] + resolved = deepcopy(self.lookup_ref(ref, schema)) + + if not isinstance(resolved, dict): + return resolved + + if is_schema_position: + return self.register_hoisted_schema(ref_url, resolved, schema) + + # Response and request body objects: hoist only the payload schema, keep the small wrapper inline + if isinstance(resolved.get('content'), dict): + for media_type, media in resolved['content'].items(): + if isinstance(media, dict) and isinstance(media.get('schema'), dict): + media['schema'] = self.register_hoisted_schema( + f'{ref_url}#{media_type}', media['schema'], schema + ) + + return resolved + + def register_hoisted_schema(self, ref_url: str, body: dict, schema: Schema): + # Identical schemas reached through differently spelled refs must collapse into one component + fingerprint = yaml.dump(body, sort_keys=True) + component_name = self.hoisted_names_by_fingerprint.get(fingerprint) + + if not component_name: + component_name = self.build_component_name(ref_url, schema) + self.hoisted_names_by_fingerprint[fingerprint] = component_name + self.schemas_to_register[component_name] = body + + self.used_refs.add(component_name) + + return {'$ref': f'#/components/schemas/{component_name}'} + + def build_component_name(self, ref_url: str, schema: Schema): + directory, _, file_name = ref_url.split('#')[0].rpartition('/') + base_name = self.to_pascal_case(re.sub(r'\.(ya?ml|json)$', '', file_name)) + + # API Gateway model names must be alphanumeric and cannot start with a digit + if not base_name[:1].isalpha(): + base_name = self.to_pascal_case(directory.rpartition('/')[2].rstrip('s')) + base_name + + taken = set(schema.get('components', {}).get('schemas', {})) | set(self.schemas_to_register) + component_name = base_name + suffix = 2 + while component_name in taken: + component_name = f'{base_name}{suffix}' + suffix += 1 + + return component_name + + @staticmethod + def to_pascal_case(value: str): + return ''.join(part[:1].upper() + part[1:] for part in re.split(r'[^A-Za-z0-9]+', value) if part) + @staticmethod def get_ref_model_name(ref): return ref.split('/')[-1] @@ -203,12 +276,14 @@ def merge_all_of(self, node, schema: Schema): class ApiGatewayProcessor(AbstractProcessor): - def __init__(self, config: Config, integration_host, connection_id, remove_scopes, remove_descriptions, **kwargs) -> None: + def __init__(self, config: Config, integration_host, connection_id, remove_scopes, remove_descriptions, + remove_examples=False, **kwargs) -> None: super().__init__(config) self.integration_host = integration_host self.connection_id = connection_id self.remove_scopes = remove_scopes self.remove_descriptions = remove_descriptions + self.remove_examples = remove_examples def process(self, schema: Schema) -> Schema: for path in schema['paths']: @@ -237,6 +312,11 @@ def process(self, schema: Schema) -> Schema: for model_name in schema['components'].get('schemas', {}): self._remove_descriptions(schema['components']['schemas'][model_name]) + # Examples are documentation only, API Gateway ignores them + if self.remove_examples: + self._remove_examples(schema['paths']) + self._remove_examples(schema['components']) + # Replace all authorizers with API key type for authorizer in schema['components'].get('securitySchemes', {}): scheme = schema['components']['securitySchemes'][authorizer] @@ -264,6 +344,23 @@ def _remove_descriptions(self, schema: object): self._remove_descriptions(schema['items']['properties'][property_name]) + def _remove_examples(self, node: object): + if isinstance(node, dict): + node.pop('example', None) + node.pop('examples', None) + + for key, value in node.items(): + # Never treat a schema property literally named "example" as a keyword + if key == 'properties' and isinstance(value, dict): + for property_schema in value.values(): + self._remove_examples(property_schema) + elif key != 'x-amazon-apigateway-integration': + self._remove_examples(value) + + elif isinstance(node, list): + for item in node: + self._remove_examples(item) + def _get_response_codes(self, schema, path, method): responses = { 'default': { diff --git a/setup.py b/setup.py index bfd3c25..9884f8d 100644 --- a/setup.py +++ b/setup.py @@ -22,15 +22,17 @@ def readme(): ] setup( - name='api-deploy', + name='packmatic-api-deploy', version=VERSION, - url='https://github.com/fabfuel/api-deploy', - download_url='https://github.com/fabfuel/api-deploy/archive/%s.tar.gz' % VERSION, + url='https://github.com/Packmatic/api-deploy', license='BSD-3-Clause', author='Fabian Fuelling', author_email='pypi@fabfuel.de', - description='Manage Amazon REST API Gateway deployments', + maintainer='Packmatic Tech', + maintainer_email='tech@packmatic.io', + description='Manage Amazon REST API Gateway deployments (Packmatic fork of api-deploy)', long_description=readme(), + long_description_content_type='text/markdown', packages=find_packages(exclude=['tests']), include_package_data=True, zip_safe=False, diff --git a/tests/functional/api.dedup.yml b/tests/functional/api.dedup.yml new file mode 100644 index 0000000..1fa7e51 --- /dev/null +++ b/tests/functional/api.dedup.yml @@ -0,0 +1,28 @@ +gateway: + integrationHost: https://integration.com + connectionId: 1234567890 + removeScopes: true + removeDescriptions: true + removeExamples: true +flatten: + dedupExternalRefs: true +headers: + request: + - host + - authorization + - x-datadog-trace-id + - x-datadog-parent-id + - x-datadog-origin + - x-datadog-sampling-priority + response: + - Location +cors: + origin: 'foo.com' +static: + files: + - api.v1.yml +strict: + enabled: true + overwriteRequired: false + blocklist: + - _links diff --git a/tests/functional/test_compile.py b/tests/functional/test_compile.py index cf1b527..b071555 100644 --- a/tests/functional/test_compile.py +++ b/tests/functional/test_compile.py @@ -9,6 +9,8 @@ filename = os.path.join(dirname, 'api.yml') config = Config.from_file(filename) +dedup_config = Config.from_file(os.path.join(dirname, 'api.dedup.yml')) + @fixture def simple_source_file(): @@ -52,6 +54,12 @@ def external_ref_target_file(): return Schema.from_file(filename) +@fixture +def dedup_target_file(): + filename = os.path.join(dirname, '../openapi/dedup_target.yml') + return Schema.from_file(filename) + + @fixture def one_of_source_file(): filename = os.path.join(dirname, '../openapi/one_of_source.yml') @@ -87,6 +95,12 @@ def test_compile_external_ref(external_ref_source_file, external_ref_target_file assert processed.dump(True) == external_ref_target_file.dump(True) +def test_compile_dedup_external_refs(external_ref_source_file, dedup_target_file): + manager = ProcessManager.default(dedup_config) + processed = manager.process(external_ref_source_file) + assert processed.dump(True) == dedup_target_file.dump(True) + + def test_compile_one_of(one_of_source_file, one_of_target_file): manager = ProcessManager.default(config) processed = manager.process(one_of_source_file) diff --git a/tests/openapi/dedup_target.yml b/tests/openapi/dedup_target.yml new file mode 100644 index 0000000..98acd54 --- /dev/null +++ b/tests/openapi/dedup_target.yml @@ -0,0 +1,856 @@ +openapi: 3.0.1 +info: + description: Test API + title: Test + version: '1' + contact: + name: Fabian Fuelling + email: api@fabfuel.de +servers: +- url: https://api.fabfuel.de/test +tags: +- name: Health + description: Ping and health check endpoints +- description: Static file content + name: Static Files +- name: CORS + description: CORS configuration endpoints +paths: + /-/health: + get: + operationId: health + description: HealthJSON Health check endpoint + tags: + - Health + security: + - auth0: [] + responses: + '200': + description: Health JSON response + content: + application/json: + schema: + $ref: '#/components/schemas/Response200Health' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Response401Unauthorized' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Response403Forbidden' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Response500ServerError' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + parameters: + - name: host + in: header + required: false + schema: + type: string + - name: authorization + in: header + required: false + schema: + type: string + - name: x-datadog-trace-id + in: header + required: false + schema: + type: string + - name: x-datadog-parent-id + in: header + required: false + schema: + type: string + - name: x-datadog-origin + in: header + required: false + schema: + type: string + - name: x-datadog-sampling-priority + in: header + required: false + schema: + type: string + x-amazon-apigateway-integration: + type: http + connectionId: 1234567890 + httpMethod: GET + uri: https://integration.com/-/health + passthroughBehavior: when_no_match + connectionType: VPC_LINK + responses: + default: + statusCode: '500' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '200': + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '401': + statusCode: '401' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '403': + statusCode: '403' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestParameters: + integration.request.header.User-Agent: context.identity.userAgent + integration.request.header.X-Forwarded-For: context.identity.sourceIp + integration.request.header.X-Stage: context.stage + integration.request.header.host: method.request.header.host + integration.request.header.authorization: method.request.header.authorization + integration.request.header.x-datadog-trace-id: method.request.header.x-datadog-trace-id + integration.request.header.x-datadog-parent-id: method.request.header.x-datadog-parent-id + integration.request.header.x-datadog-origin: method.request.header.x-datadog-origin + integration.request.header.x-datadog-sampling-priority: method.request.header.x-datadog-sampling-priority + options: + operationId: /-/healthCORS + description: /-/health CORS + tags: + - CORS + responses: + '200': + description: '200' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + Access-Control-Allow-Methods: + schema: + type: string + example: GET,OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: '''GET,OPTIONS''' + method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + /-/ping: + get: + operationId: ping + description: HealthJSON Ping endpoint + tags: + - Health + responses: + '200': + description: Ping response + content: {} + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Response500ServerError' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + parameters: + - name: host + in: header + required: false + schema: + type: string + - name: authorization + in: header + required: false + schema: + type: string + - name: x-datadog-trace-id + in: header + required: false + schema: + type: string + - name: x-datadog-parent-id + in: header + required: false + schema: + type: string + - name: x-datadog-origin + in: header + required: false + schema: + type: string + - name: x-datadog-sampling-priority + in: header + required: false + schema: + type: string + x-amazon-apigateway-integration: + type: http + connectionId: 1234567890 + httpMethod: GET + uri: https://integration.com/-/ping + passthroughBehavior: when_no_match + connectionType: VPC_LINK + responses: + default: + statusCode: '500' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '200': + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestParameters: + integration.request.header.User-Agent: context.identity.userAgent + integration.request.header.X-Forwarded-For: context.identity.sourceIp + integration.request.header.X-Stage: context.stage + integration.request.header.host: method.request.header.host + integration.request.header.authorization: method.request.header.authorization + integration.request.header.x-datadog-trace-id: method.request.header.x-datadog-trace-id + integration.request.header.x-datadog-parent-id: method.request.header.x-datadog-parent-id + integration.request.header.x-datadog-origin: method.request.header.x-datadog-origin + integration.request.header.x-datadog-sampling-priority: method.request.header.x-datadog-sampling-priority + options: + operationId: /-/pingCORS + description: /-/ping CORS + tags: + - CORS + responses: + '200': + description: '200' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + Access-Control-Allow-Methods: + schema: + type: string + example: GET,OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: '''GET,OPTIONS''' + method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + /test: + get: + tags: + - Test + summary: Test + operationId: test + responses: + '200': + description: Test + content: + application/json: + schema: + $ref: '#/components/schemas/Test' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + parameters: + - name: host + in: header + required: false + schema: + type: string + - name: authorization + in: header + required: false + schema: + type: string + - name: x-datadog-trace-id + in: header + required: false + schema: + type: string + - name: x-datadog-parent-id + in: header + required: false + schema: + type: string + - name: x-datadog-origin + in: header + required: false + schema: + type: string + - name: x-datadog-sampling-priority + in: header + required: false + schema: + type: string + x-amazon-apigateway-integration: + type: http + connectionId: 1234567890 + httpMethod: GET + uri: https://integration.com/test + passthroughBehavior: when_no_match + connectionType: VPC_LINK + responses: + default: + statusCode: '500' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '200': + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestParameters: + integration.request.header.User-Agent: context.identity.userAgent + integration.request.header.X-Forwarded-For: context.identity.sourceIp + integration.request.header.X-Stage: context.stage + integration.request.header.host: method.request.header.host + integration.request.header.authorization: method.request.header.authorization + integration.request.header.x-datadog-trace-id: method.request.header.x-datadog-trace-id + integration.request.header.x-datadog-parent-id: method.request.header.x-datadog-parent-id + integration.request.header.x-datadog-origin: method.request.header.x-datadog-origin + integration.request.header.x-datadog-sampling-priority: method.request.header.x-datadog-sampling-priority + options: + operationId: /testCORS + description: /test CORS + tags: + - CORS + responses: + '200': + description: '200' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + Access-Control-Allow-Methods: + schema: + type: string + example: GET,OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: '''GET,OPTIONS''' + method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + /test-two: + get: + tags: + - Test + summary: Test 2 + operationId: test2 + responses: + '200': + description: Test 2 + content: + application/json: + schema: + $ref: '#/components/schemas/TestTwo' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + parameters: + - name: host + in: header + required: false + schema: + type: string + - name: authorization + in: header + required: false + schema: + type: string + - name: x-datadog-trace-id + in: header + required: false + schema: + type: string + - name: x-datadog-parent-id + in: header + required: false + schema: + type: string + - name: x-datadog-origin + in: header + required: false + schema: + type: string + - name: x-datadog-sampling-priority + in: header + required: false + schema: + type: string + x-amazon-apigateway-integration: + type: http + connectionId: 1234567890 + httpMethod: GET + uri: https://integration.com/test-two + passthroughBehavior: when_no_match + connectionType: VPC_LINK + responses: + default: + statusCode: '500' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '200': + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestParameters: + integration.request.header.User-Agent: context.identity.userAgent + integration.request.header.X-Forwarded-For: context.identity.sourceIp + integration.request.header.X-Stage: context.stage + integration.request.header.host: method.request.header.host + integration.request.header.authorization: method.request.header.authorization + integration.request.header.x-datadog-trace-id: method.request.header.x-datadog-trace-id + integration.request.header.x-datadog-parent-id: method.request.header.x-datadog-parent-id + integration.request.header.x-datadog-origin: method.request.header.x-datadog-origin + integration.request.header.x-datadog-sampling-priority: method.request.header.x-datadog-sampling-priority + options: + operationId: /test-twoCORS + description: /test-two CORS + tags: + - CORS + responses: + '200': + description: '200' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + Access-Control-Allow-Methods: + schema: + type: string + example: GET,OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: '''GET,OPTIONS''' + method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock + /api.v1.yml: + get: + tags: + - Static Files + responses: + '200': + description: Static file api.v1.yml + headers: + Content-Type: + schema: + type: string + Access-Control-Expose-Headers: + schema: + type: string + example: Content-Type + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + parameters: + - name: host + in: header + required: false + schema: + type: string + - name: authorization + in: header + required: false + schema: + type: string + - name: x-datadog-trace-id + in: header + required: false + schema: + type: string + - name: x-datadog-parent-id + in: header + required: false + schema: + type: string + - name: x-datadog-origin + in: header + required: false + schema: + type: string + - name: x-datadog-sampling-priority + in: header + required: false + schema: + type: string + x-amazon-apigateway-integration: + type: http + connectionId: 1234567890 + httpMethod: GET + uri: https://integration.com/api.v1.yml + passthroughBehavior: when_no_match + connectionType: VPC_LINK + responses: + default: + statusCode: '500' + responseParameters: + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + '200': + statusCode: '200' + responseParameters: + method.response.header.Content-Type: integration.response.header.Content-Type + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + method.response.header.Access-Control-Expose-Headers: '''Content-Type''' + requestParameters: + integration.request.header.User-Agent: context.identity.userAgent + integration.request.header.X-Forwarded-For: context.identity.sourceIp + integration.request.header.X-Stage: context.stage + integration.request.header.host: method.request.header.host + integration.request.header.authorization: method.request.header.authorization + integration.request.header.x-datadog-trace-id: method.request.header.x-datadog-trace-id + integration.request.header.x-datadog-parent-id: method.request.header.x-datadog-parent-id + integration.request.header.x-datadog-origin: method.request.header.x-datadog-origin + integration.request.header.x-datadog-sampling-priority: method.request.header.x-datadog-sampling-priority + options: + operationId: /api.v1.ymlCORS + description: /api.v1.yml CORS + tags: + - CORS + responses: + '200': + description: '200' + headers: + Access-Control-Allow-Origin: + schema: + type: string + example: foo.com + Access-Control-Allow-Methods: + schema: + type: string + example: GET,OPTIONS + Access-Control-Allow-Headers: + schema: + type: string + example: authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id + x-amazon-apigateway-integration: + responses: + default: + statusCode: '200' + responseParameters: + method.response.header.Access-Control-Allow-Methods: '''GET,OPTIONS''' + method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' + method.response.header.Access-Control-Allow-Origin: '''foo.com''' + requestTemplates: + application/json: '{"statusCode": 200}' + passthroughBehavior: when_no_match + type: mock +components: + schemas: + Test: + type: object + readOnly: true + required: + - id + - urn + - tenant + - createdAt + - createdBy + - modifiedAt + - modifiedBy + additionalProperties: false + properties: + id: + type: string + readOnly: true + urn: + type: string + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + tenant: + type: string + pattern: ^[a-z]{2,}(?:\.[a-z]{1}[a-z-]*[a-z]{1})*$ + readOnly: true + packmaticId: + type: string + pattern: ^[A-Z]{2}-[A-Z0-9]{5,7}$ + readOnly: true + createdAt: + type: string + format: date-time + createdBy: + type: string + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + modifiedAt: + type: string + format: date-time + nullable: true + modifiedBy: + type: string + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + nullable: true + additionalField: + type: string + TestTwo: + type: object + readOnly: true + required: + - id + - urn + - tenant + - createdAt + - createdBy + - modifiedAt + - modifiedBy + additionalProperties: false + properties: + id: + type: string + readOnly: true + urn: + type: string + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + tenant: + type: string + pattern: ^[a-z]{2,}(?:\.[a-z]{1}[a-z-]*[a-z]{1})*$ + readOnly: true + packmaticId: + type: string + pattern: ^[A-Z]{2}-[A-Z0-9]{5,7}$ + readOnly: true + createdAt: + type: string + format: date-time + createdBy: + type: string + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + modifiedAt: + type: string + format: date-time + nullable: true + modifiedBy: + type: string + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + nullable: true + Response200Health: + type: object + additionalProperties: false + properties: + application: + type: object + additionalProperties: false + properties: + name: + type: string + environment: + type: string + version: + type: string + required: + - name + - environment + - version + instance: + type: object + additionalProperties: false + properties: + datetime: + type: string + format: date-time + uptime: + type: string + startup: + type: string + format: date-time + hostname: + type: string + required: + - datetime + - uptime + - startup + - hostname + required: + - application + - instance + Response401Unauthorized: + type: object + required: + - statusCode + - message + additionalProperties: false + properties: + statusCode: + type: integer + message: + type: string + error: + type: string + incident: + type: string + status: + type: integer + name: + type: string + path: + type: string + errors: + type: array + items: + type: object + properties: + message: + type: string + errorCode: + type: string + path: + type: string + additionalProperties: false + required: + - message + - errorCode + - path + Response403Forbidden: + type: object + required: + - statusCode + - message + additionalProperties: false + properties: + statusCode: + type: integer + message: + type: string + error: + type: string + incident: + type: string + status: + type: integer + name: + type: string + path: + type: string + errors: + type: array + items: + type: object + properties: + message: + type: string + errorCode: + type: string + path: + type: string + additionalProperties: false + required: + - message + - errorCode + - path + Response500ServerError: + type: object + required: + - statusCode + - message + additionalProperties: false + properties: + statusCode: + type: integer + message: + type: string + error: + type: string + incident: + type: string + status: + type: integer + name: + type: string + path: + type: string + errors: + type: array + items: + type: object + properties: + message: + type: string + errorCode: + type: string + path: + type: string + additionalProperties: false + required: + - message + - errorCode + - path + securitySchemes: + supplierAuth: + type: apiKey + in: header + name: x-supplier-auth +x-amazon-apigateway-request-validators: {} +x-amazon-apigateway-minimum-compression-size: 0 diff --git a/tests/openapi/external_ref_target.yml b/tests/openapi/external_ref_target.yml index adc47c5..3d8a078 100644 --- a/tests/openapi/external_ref_target.yml +++ b/tests/openapi/external_ref_target.yml @@ -83,16 +83,48 @@ paths: statusCode: type: integer example: 401 + description: Deprecated HTTP status code message: type: string example: Unauthorized + description: Deprecated human readable error message error: type: string example: Unauthorized + description: Deprecated machine readable error message incident: description: Unique incident identifier type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: + type: string + example: Bad Request + description: HTTP status name + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: @@ -112,16 +144,48 @@ paths: statusCode: type: integer example: 403 + description: Deprecated HTTP status code message: type: string example: Forbidden resource + description: Deprecated human readable error message error: type: string example: Forbidden + description: Deprecated machine readable error message incident: description: Unique incident identifier type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: + type: string + example: Bad Request + description: HTTP status name + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: @@ -141,16 +205,48 @@ paths: statusCode: type: integer example: 500 + description: Deprecated HTTP status code message: type: string example: Internal Server Error + description: Deprecated human readable error message error: type: string example: Internal Server Error + description: Deprecated machine readable error message incident: description: Unique incident identifier type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: + type: string + example: Bad Request + description: HTTP status name + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: @@ -283,16 +379,48 @@ paths: statusCode: type: integer example: 500 + description: Deprecated HTTP status code message: type: string example: Internal Server Error + description: Deprecated human readable error message error: type: string example: Internal Server Error + description: Deprecated machine readable error message incident: description: Unique incident identifier type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: + type: string + example: Bad Request + description: HTTP status name + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: @@ -731,15 +859,13 @@ components: - modifiedBy additionalProperties: false properties: - additionalField: - type: string id: type: string - example: 5d4470a6-121b-40d2-aff9-00f24aa4a110 readOnly: true + example: 5d4470a6-121b-40d2-aff9-00f24aa4a110 urn: type: string - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 tenant: type: string @@ -756,7 +882,7 @@ components: format: date-time createdBy: type: string - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 modifiedAt: type: string @@ -764,9 +890,11 @@ components: nullable: true modifiedBy: type: string - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 nullable: true + additionalField: + type: string TestTwo: type: object readOnly: true @@ -782,11 +910,11 @@ components: properties: id: type: string - example: 5d4470a6-121b-40d2-aff9-00f24aa4a110 readOnly: true + example: 5d4470a6-121b-40d2-aff9-00f24aa4a110 urn: type: string - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 tenant: type: string @@ -803,7 +931,7 @@ components: format: date-time createdBy: type: string - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 modifiedAt: type: string @@ -811,7 +939,7 @@ components: nullable: true modifiedBy: type: string - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 nullable: true securitySchemes: diff --git a/tests/openapi/one_of_target.yml b/tests/openapi/one_of_target.yml index 7289b3f..0f9a9b2 100644 --- a/tests/openapi/one_of_target.yml +++ b/tests/openapi/one_of_target.yml @@ -11,8 +11,8 @@ servers: tags: - name: Message description: Message sending -- name: Static Files - description: Static file content +- description: Static file content + name: Static Files - name: CORS description: CORS configuration endpoints paths: @@ -28,8 +28,8 @@ paths: application/json: schema: oneOf: - - $ref: '#/components/schemas/UserCreated' - - $ref: '#/components/schemas/ProjectCreated' + - $ref: '#/components/schemas/UserCreated' + - $ref: '#/components/schemas/ProjectCreated' discriminator: propertyName: message responses: @@ -39,102 +39,197 @@ paths: application/json: schema: oneOf: - - $ref: '#/components/schemas/UserCreated' - - $ref: '#/components/schemas/ProjectCreated' - - $ref: '#/components/schemas/MessageList' + - $ref: '#/components/schemas/UserCreated' + - $ref: '#/components/schemas/ProjectCreated' + - $ref: '#/components/schemas/MessageList' headers: Access-Control-Allow-Origin: schema: type: string - example: 'foo.com' + example: foo.com '400': description: Bad Request content: application/json: schema: + type: object + required: + - statusCode + - message additionalProperties: false properties: - error: + statusCode: + type: integer + example: 400 + description: Deprecated HTTP status code + message: + type: string example: Bad Request + description: Deprecated human readable error message + error: type: string + example: Bad Request + description: Deprecated machine readable error message incident: description: Unique incident identifier + type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: type: string - message: example: Bad Request + description: HTTP status name + path: type: string - statusCode: - example: 400 - type: integer - required: - - statusCode - - message - type: object + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: type: string - example: 'foo.com' + example: foo.com '404': description: Not Found content: application/json: schema: + type: object + required: + - statusCode + - message additionalProperties: false properties: + statusCode: + type: integer + example: 404 + description: Deprecated HTTP status code + message: + type: string + example: Can not get entity /tasks/123 + description: Deprecated human readable error message error: - example: Not Found type: string + example: Not Found + description: Deprecated machine readable error message incident: description: Unique incident identifier + type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: type: string - message: - example: Can not get entity /tasks/123 + example: Bad Request + description: HTTP status name + path: type: string - statusCode: - example: 404 - type: integer - required: - - statusCode - - message - type: object + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: type: string - example: 'foo.com' + example: foo.com '500': description: Internal Server Error content: application/json: schema: + type: object + required: + - statusCode + - message additionalProperties: false properties: - error: + statusCode: + type: integer + example: 500 + description: Deprecated HTTP status code + message: + type: string example: Internal Server Error + description: Deprecated human readable error message + error: type: string + example: Internal Server Error + description: Deprecated machine readable error message incident: description: Unique incident identifier + type: string example: 5beb965c-7ffc-468b-a063-eb47c5b366c2 + status: + type: integer + example: 400 + description: HTTP status code + name: type: string - message: - example: Internal Server Error + example: Bad Request + description: HTTP status name + path: type: string - statusCode: - example: 500 - type: integer - required: - - statusCode - - message - type: object - + example: /pm/v1/foobar + description: Path of where the error occurred + errors: + type: array + items: + type: object + properties: + message: + type: string + example: Invalid value + description: Human readable error message + errorCode: + type: string + example: SomeCustomError + description: Machine readable error code of what happened + path: + type: string + example: /pm/v1/foobar + description: Path of where the error occurred headers: Access-Control-Allow-Origin: schema: type: string - example: 'foo.com' + example: foo.com x-amazon-apigateway-request-validator: all parameters: - name: host @@ -162,8 +257,8 @@ paths: required: false schema: type: string - - in: header - name: x-datadog-sampling-priority + - name: x-datadog-sampling-priority + in: header required: false schema: type: string @@ -213,7 +308,7 @@ paths: Access-Control-Allow-Origin: schema: type: string - example: 'foo.com' + example: foo.com Access-Control-Allow-Methods: schema: type: string @@ -242,18 +337,18 @@ paths: '200': description: Static file api.v1.yml headers: - Access-Control-Allow-Origin: + Content-Type: schema: + example: text/yaml type: string - example: 'foo.com' Access-Control-Expose-Headers: schema: - example: Content-Type type: string - Content-Type: + example: Content-Type + Access-Control-Allow-Origin: schema: - example: text/yaml type: string + example: foo.com parameters: - name: host in: header @@ -300,9 +395,9 @@ paths: '200': statusCode: '200' responseParameters: + method.response.header.Content-Type: integration.response.header.Content-Type method.response.header.Access-Control-Allow-Origin: '''foo.com''' method.response.header.Access-Control-Expose-Headers: '''Content-Type''' - method.response.header.Content-Type: integration.response.header.Content-Type requestParameters: integration.request.header.User-Agent: context.identity.userAgent integration.request.header.X-Forwarded-For: context.identity.sourceIp @@ -325,7 +420,7 @@ paths: Access-Control-Allow-Origin: schema: type: string - example: 'foo.com' + example: foo.com Access-Control-Allow-Methods: schema: type: string @@ -339,8 +434,8 @@ paths: default: statusCode: '200' responseParameters: - method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' method.response.header.Access-Control-Allow-Methods: '''GET,OPTIONS''' + method.response.header.Access-Control-Allow-Headers: '''authorization,host,x-datadog-origin,x-datadog-parent-id,x-datadog-sampling-priority,x-datadog-trace-id''' method.response.header.Access-Control-Allow-Origin: '''foo.com''' requestTemplates: application/json: '{"statusCode": 200}' @@ -348,89 +443,86 @@ paths: type: mock components: schemas: + MessageList: + type: array + items: + type: object + additionalProperties: true UserCreated: type: object - additionalProperties: false properties: recipients: type: array minItems: 1 example: - - urn:pm:users:pm.demo:user/1234-123456-123456-1234 - - urn:pm:users:pm.demo:role/customer + - urn:pm:users:pm.demo:user/1234-123456-123456-1234 + - urn:pm:users:pm.demo:role/customer items: - example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ type: string - + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 subject: type: string message: type: string enum: - - UserCreated - - ProjectCreated + - UserCreated + - ProjectCreated parameters: type: object - additionalProperties: false properties: name: type: string email: type: string + additionalProperties: false required: - - name - - email + - name + - email + additionalProperties: false required: - - recipients - - subject - - message - - parameters - - MessageList: - items: - additionalProperties: true - type: object - type: array + - recipients + - subject + - message + - parameters ProjectCreated: type: object - additionalProperties: false properties: recipients: type: array minItems: 1 example: - - urn:pm:users:pm.demo:user/1234-123456-123456-1234 - - urn:pm:users:pm.demo:role/customer + - urn:pm:users:pm.demo:user/1234-123456-123456-1234 + - urn:pm:users:pm.demo:role/customer items: - example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 - pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+$ type: string - + pattern: ^urn:[\w-]+:[\w.-]*:[\w.-]*:[\w-]+\/[^\/]+(\/[^\/]+)*$ + example: urn:pm:service::foobar/d9a6fc2e-a4b3-4fba-9f20-c5bd2fdb5071 subject: type: string message: type: string enum: - - UserCreated - - ProjectCreated + - UserCreated + - ProjectCreated parameters: type: object - additionalProperties: false properties: label: type: string start: type: string format: datetime + additionalProperties: false required: - label - start + additionalProperties: false required: - - recipients - - subject - - message - - parameters + - recipients + - subject + - message + - parameters x-amazon-apigateway-request-validators: all: validateRequestParameters: true diff --git a/tests/unit/test_flatten_dedup.py b/tests/unit/test_flatten_dedup.py new file mode 100644 index 0000000..07d7fbd --- /dev/null +++ b/tests/unit/test_flatten_dedup.py @@ -0,0 +1,139 @@ +from api_deploy.config import Config, ConfigFile +from api_deploy.converters import FlattenProcessor +from api_deploy.schema import Schema, YamlDict + +ERROR_RESPONSE = ''' +description: Internal Server Error +content: + application/json: + schema: + type: object + required: + - message + properties: + message: + type: string + example: Internal Server Error +''' + +URN_SCHEMA = ''' +type: string +description: Uniform Resource Name +example: urn:pm:service::foobar/1 +''' + +EXTERNAL_SCHEMAS = { + 'https://api.example.com/types/responses/500-server-error.yml': ERROR_RESPONSE, + # Same body, different URL: must collapse into a single component + 'https://api.example.com/types/mirror/500-server-error.yml': ERROR_RESPONSE, + 'https://api.example.com/types/schemas/urn.yml': URN_SCHEMA, +} + + +class OfflineFlattenProcessor(FlattenProcessor): + """The real processor fetches external refs over HTTP; serve them from a dict instead.""" + + def get_external_schema(self, url): + return YamlDict(EXTERNAL_SCHEMAS[url]) + + +def build_source(mirror_ref=False): + second_ref = ('https://api.example.com/types/mirror/500-server-error.yml' if mirror_ref + else 'https://api.example.com/types/responses/500-server-error.yml') + return Schema(f''' +openapi: 3.0.1 +info: + title: Test + version: '1' +servers: + - url: http://localhost +tags: [] +paths: + /foo: + get: + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + urn: + $ref: 'https://api.example.com/types/schemas/urn.yml' + '500': + $ref: 'https://api.example.com/types/responses/500-server-error.yml' + /bar: + get: + responses: + '500': + $ref: '{second_ref}' +components: + schemas: {{}} +''') + + +def build_processor(dedup_external_refs): + config = Config(ConfigFile(f'flatten:\n dedupExternalRefs: {str(dedup_external_refs).lower()}\n'), 'test') + assert config['flatten']['dedup_external_refs'] is dedup_external_refs + return OfflineFlattenProcessor(config, **config['flatten']) + + +def response_schema(schema, path): + return schema['paths'][path]['get']['responses']['500']['content']['application/json']['schema'] + + +def test_external_refs_are_inlined_by_default(): + processed = build_processor(dedup_external_refs=False).process(build_source()) + + for path in ('/foo', '/bar'): + inlined = response_schema(processed, path) + assert '$ref' not in inlined + assert inlined['properties']['message']['type'] == 'string' + + assert processed['components']['schemas'] == {} + + +def test_dedup_hoists_response_payload_into_components(): + processed = build_processor(dedup_external_refs=True).process(build_source()) + + ref = {'$ref': '#/components/schemas/Response500ServerError'} + assert response_schema(processed, '/foo') == ref + assert response_schema(processed, '/bar') == ref + + hoisted = processed['components']['schemas']['Response500ServerError'] + assert hoisted['properties']['message']['type'] == 'string' + assert hoisted['required'] == ['message'] + + # The response wrapper itself must stay inline, only the payload schema is shared + assert processed['paths']['/foo']['get']['responses']['500']['description'] == 'Internal Server Error' + + +def test_hoisted_component_names_are_api_gateway_safe(): + processed = build_processor(dedup_external_refs=True).process(build_source()) + + for name in processed['components']['schemas']: + assert name.isalnum(), f'{name} is not alphanumeric' + assert name[0].isalpha(), f'{name} must not start with a digit' + + +def test_identical_bodies_reached_via_different_urls_collapse(): + processed = build_processor(dedup_external_refs=True).process(build_source(mirror_ref=True)) + + assert len(processed['components']['schemas']) == 1 + assert response_schema(processed, '/foo') == response_schema(processed, '/bar') + + +def test_refs_nested_in_properties_stay_inline(): + processed = build_processor(dedup_external_refs=True).process(build_source()) + + urn = processed['paths']['/foo']['get']['responses']['200']['content']['application/json']['schema'] + assert urn['properties']['urn']['type'] == 'string' + assert '$ref' not in urn['properties']['urn'] + + +def test_dedup_leaves_no_external_refs(): + processed = build_processor(dedup_external_refs=True).process(build_source()) + + dumped = processed.dump() + assert 'https://' not in dumped.replace('http://localhost', '') \ No newline at end of file diff --git a/tests/unit/test_remove_examples.py b/tests/unit/test_remove_examples.py new file mode 100644 index 0000000..999f3cb --- /dev/null +++ b/tests/unit/test_remove_examples.py @@ -0,0 +1,96 @@ +from api_deploy.config import Config, ConfigFile +from api_deploy.converters import ApiGatewayProcessor +from api_deploy.schema import Schema + + +def build_source(): + return Schema(''' +openapi: 3.0.1 +info: + title: Test + version: '1' +servers: + - url: http://localhost +tags: [] +paths: + /foo: + get: + parameters: + - name: filter + in: query + schema: + type: string + example: abc + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: Foo + example: + type: string + example: this property is literally named "example" + examples: + sample: + value: {name: Foo} +components: + schemas: + Thing: + type: object + example: {a: 1} + properties: + a: + type: integer + example: 1 +''') + + +def build_processor(remove_examples): + config = Config(ConfigFile(f'gateway:\n removeExamples: {str(remove_examples).lower()}\n'), 'test') + assert config['gateway']['remove_examples'] is remove_examples + return ApiGatewayProcessor(config, **config['gateway']) + + +def response_schema(schema): + return schema['paths']['/foo']['get']['responses']['200']['content']['application/json'] + + +def test_examples_are_kept_by_default(): + processed = build_processor(remove_examples=False).process(build_source()) + + assert response_schema(processed)['schema']['properties']['name']['example'] == 'Foo' + assert processed['components']['schemas']['Thing']['example'] == {'a': 1} + + +def test_examples_are_removed_when_enabled(): + processed = build_processor(remove_examples=True).process(build_source()) + + media = response_schema(processed) + assert 'example' not in media['schema']['properties']['name'] + assert 'examples' not in media + assert 'example' not in processed['components']['schemas']['Thing'] + assert 'example' not in processed['components']['schemas']['Thing']['properties']['a'] + assert 'example' not in processed['paths']['/foo']['get']['parameters'][0]['schema'] + + +def test_a_property_named_example_is_never_treated_as_a_keyword(): + processed = build_processor(remove_examples=True).process(build_source()) + + properties = response_schema(processed)['schema']['properties'] + assert 'example' in properties, 'a schema property named "example" must survive' + assert properties['example']['type'] == 'string' + # ...but its own annotation is still stripped + assert 'example' not in properties['example'] + + +def test_integration_blocks_are_left_alone(): + processed = build_processor(remove_examples=True).process(build_source()) + + integration = processed['paths']['/foo']['get']['x-amazon-apigateway-integration'] + assert integration['type'] == 'http' + assert integration['responses']['200']['statusCode'] == '200' \ No newline at end of file