Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 19 additions & 15 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Docker Hub
name: Docker

concurrency:
cancel-in-progress: true
Expand All @@ -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
push: true
tags: fabfuel/api-deploy:${{ github.ref_name }}
tags: ghcr.io/packmatic/api-deploy:${{ github.ref_name }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need any permissions setup for this?

@umaan1982 umaan1982 Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It uses packages:write

-
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
push: true
tags: fabfuel/api-deploy:latest
tags: ghcr.io/packmatic/api-deploy:latest
110 changes: 109 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,109 @@
# API Deploy
# 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 <config> <source> <target>
```

## Usage

```bash
api compile <config> <source> <target> # compile only
api deploy <config> <api-id> <stage> <source>
```

## 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.
2 changes: 1 addition & 1 deletion api_deploy/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
VERSION = '0.17.0'
VERSION = '0.18.0'
5 changes: 5 additions & 0 deletions api_deploy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def __init__(self, config_file: ConfigFile, file_path) -> None:
'response': [],
},
'strict': {},
'flatten': {},
'gateway': {},
'cors': {},
'static': {
Expand All @@ -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', '*'))

Expand Down
103 changes: 100 additions & 3 deletions api_deploy/converters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import re
from copy import deepcopy

import yaml
from mergedeep import merge
from requests import get, HTTPError

Expand All @@ -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']))
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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']))
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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']:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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': {
Expand Down
Loading