ThatXliner/shotgun

Turn any REST API into the shape of another

1

stars

19

commits

HTML

primary language

Aug 21, 2026

updated

bryanhu.com/shotgun/

README

Shotgun — one API shape in, another out

Shotgun

Turn any REST API into the shape of another.

Shotgun is a reverse proxy generator. Give it two OpenAPI specs—the API you want to expose and the upstream API you're actually calling—and it diffs them, auto-maps what lines up, and runs a proxy that translates requests and responses between the two shapes.

Two APIs in the same domain (Git forges, payment processors, CMS platforms) usually share 60-80% of their structure. Shotgun maps the obvious parts and leaves you a checklist for the rest.

Flagship use case: Anvil. It points GitHub-shaped tooling (gh, Renovate, CI scripts) at a self-hosted Forgejo instance — a curated mappings.toml on top of this proxy, no client-side changes needed. Anvil is what's driving Shotgun's feature work against real, independently-evolved specs instead of toy fixtures.

Install

cargo install shotgun-proxy

Quick start

# 1. Diff two specs and generate a mapping file
shotgun init --source github-api.json --target forgejo-api.json --output mappings.toml

# 2. Review mappings.toml — fill in anything left as target = ""

# 3. Run the proxy
#    --target-url is where Shotgun forwards requests to (e.g. your Forgejo instance)
shotgun serve --mappings mappings.toml --target-url https://forgejo.example.com

# 4. Now clients call Shotgun using the GitHub API shape — it translates on the fly
curl http://localhost:8080/repos/owner/repo   # Shotgun calls https://forgejo.example.com/api/v1/repos/owner/repo

When specs change, re-diff without losing your edits:

shotgun sync --source github-api-v2.json --target forgejo-api-v2.json --mappings mappings.toml

Check a mapping file for problems:

shotgun validate --mappings mappings.toml

How matching works

Matching/rewriting is deterministic.

  • Endpoints are matched by normalized path ({param} names are ignored) + HTTP method, then by operationId. Everything else is left unmapped for you.
  • Fields are matched by exact name. Same name + compatible type = auto-mapped. Same name + incompatible type = flagged. Fields on only one side become defaults or drops — the todo list for adding renames.

Nested schemas (e.g. a User inside a Repository) are diffed once and reused everywhere they appear.

The mapping file

What to rewrite is cached in a mappings.toml file.

TOML syntax note: [[endpoints]] (double brackets) starts a new entry in a list — each one defines a separate endpoint. [endpoints.response.renames] (single brackets) is a sub-section of the most recent [[endpoints]] above it, not a new endpoint.

# --- Provenance ---
# Auto-generated by `shotgun init`; records which specs produced this file.

[meta]
source_spec = "github-api.json"       # path to the source OpenAPI spec
target_spec = "forgejo-api.json"      # path to the target OpenAPI spec
generated_at = "2026-08-19T01:01:13Z" # when this file was first generated
shotgun_version = "0.1.0"

# --- Global settings ---

[settings]
target_base_path = "/api/v1"           # prepended to every target path
source_base_path = "/api/v3"           # stripped from the incoming request path before matching --
                                        # for clients that inject a fixed prefix that isn't part of
                                        # the API's logical shape (e.g. the `gh` CLI always requests
                                        # /api/v3/... for any host that isn't literally github.com)
unmapped_endpoint_behavior = "reject"   # "reject" returns 501; "passthrough" forwards as-is
unmapped_field_behavior = "passthrough" # "passthrough" keeps unknown fields;
                                        # "drop" removes all; "drop_unknown" removes unlisted

# Pagination translation between source and target conventions.
[settings.pagination]
source_style = "link_header"  # how the source API paginates
target_style = "link_header"  # how the target API paginates
rewrite_link_urls = true      # rewrite pagination URLs in Link headers to point at the proxy

# Maps source pagination query params to target equivalents.
[settings.pagination.param_map]
per_page = "limit"  # source's "per_page" becomes target's "limit"

# --- Endpoints ---
# Each [[endpoints]] entry maps one source route to one target route.

# A fully mapped endpoint with field-level customizations.
[[endpoints]]
source = "GET /repos/{owner}/{repo}/issues/{issue_number}"  # the route clients call (GitHub shape)
target = "GET /repos/{owner}/{repo}/issues/{index}"        # the upstream route Shotgun actually calls (Forgejo shape)
edited = true  # true = `shotgun sync` will not overwrite this entry on re-diff

# Maps source path parameters to target path parameters.
[endpoints.path_params]
issue_number = "index"  # GitHub's {issue_number} becomes Forgejo's {index}

# Target-only fields to strip from proxied responses.
# Source-API clients don't expect these, so they're hidden.
[endpoints.response]
drops = ["due_date"]

# Field renames in the response body: source_name = "target_name".
# Shotgun never auto-generates these — every rename is a human decision.
[endpoints.response.renames]
number = "index"

# Default values for source-only fields (not present in target responses).
# Shotgun synthesizes these so clients always see the fields they expect.
[endpoints.response.defaults]
draft = false    # boolean fields get false
labels = []      # array fields get []

# Fields with the same name but incompatible types across source and target.
# Auto-diff flags these for human review — they're left untouched at runtime.
[endpoints.response.type_conflicts]
reactions = "object vs array"  # GitHub nests counts in an object, Forgejo uses an array

# Nested objects that should use a reusable [[schemas]] mapping.
# "path" is the JSON key in the response; "schema_map" names the schema below.
[[endpoints.response.nested]]
path = "user"
schema_map = "User"

# Listing endpoint with query param remapping.
[[endpoints]]
source = "GET /repos/{owner}/{repo}/issues"
target = "GET /repos/{owner}/{repo}/issues"
edited = true

# Maps source query parameters to target query parameters.
[endpoints.query_params]
per_page = "limit"
sort = "type"

# Unmapped endpoint — no target equivalent exists.
# Left with an empty target; returns 501 at runtime (when unmapped_endpoint_behavior = "reject").
[[endpoints]]
source = "POST /repos/{owner}/{repo}/issues/{issue_number}/lock"
target = ""
note = "Forgejo has no issue locking API"  # free-text annotation

# Method changes (PUT → PATCH) are supported too.
[[endpoints]]
source = "PUT /repos/{owner}/{repo}/issues/{issue_number}"
target = "PATCH /repos/{owner}/{repo}/issues/{index}"
edited = true

[endpoints.path_params]
issue_number = "index"

# Request body renames — same syntax as response renames.
[endpoints.request.renames]
number = "index"

# --- Reusable schemas ---
# Named field maps for types that appear in multiple endpoints.
# Reference them from [[endpoints.response.nested]] or [[endpoints.request.nested]]
# so the mapping is defined once and applied everywhere the type appears.

[[schemas]]
name = "User"    # the schema name, referenced by schema_map in nested entries
edited = true    # same as endpoints — protects from sync overwrite

# Same renames / defaults / drops as endpoint field mappings.
[schemas.renames]
login = "username"

[schemas.defaults]
site_admin = false

Key concepts:

  • [meta] records which specs produced this file and when.
  • [settings] controls the target base path, what happens to unmapped endpoints/fields, pagination translation, and synthesized response headers.
  • Renames map source field names to target field names. Shotgun never writes these — every rename is something a human added.
  • Defaults synthesize source-only fields with a zero value so clients always see them.
  • Drops hide target-only fields that source-API clients don't expect.
  • Type conflicts flag fields with the same name but incompatible types for human review.
  • path_params / query_params / headers rename parameters and headers between source and target.
  • nested delegates a sub-object to a named [[schemas]] entry, so the same field map is reused everywhere that type appears.
  • edited = true marks entries that shotgun sync should leave alone on re-diff.
  • note is a free-text annotation (typically auto-generated for unmapped endpoints).

Status

Working core, not a finished product. OpenAPI 3.0/3.1 and Swagger 2.0 are supported.

License

MIT

Contributors

ThatXliner

19 commits

ThatXliner/shotgun

Turn any REST API into the shape of another

1

stars

19

commits

HTML

primary language

Aug 21, 2026

updated

bryanhu.com/shotgun/

README

Shotgun — one API shape in, another out

Shotgun

Turn any REST API into the shape of another.

Shotgun is a reverse proxy generator. Give it two OpenAPI specs—the API you want to expose and the upstream API you're actually calling—and it diffs them, auto-maps what lines up, and runs a proxy that translates requests and responses between the two shapes.

Two APIs in the same domain (Git forges, payment processors, CMS platforms) usually share 60-80% of their structure. Shotgun maps the obvious parts and leaves you a checklist for the rest.

Flagship use case: Anvil. It points GitHub-shaped tooling (gh, Renovate, CI scripts) at a self-hosted Forgejo instance — a curated mappings.toml on top of this proxy, no client-side changes needed. Anvil is what's driving Shotgun's feature work against real, independently-evolved specs instead of toy fixtures.

Install

cargo install shotgun-proxy

Quick start

# 1. Diff two specs and generate a mapping file
shotgun init --source github-api.json --target forgejo-api.json --output mappings.toml

# 2. Review mappings.toml — fill in anything left as target = ""

# 3. Run the proxy
#    --target-url is where Shotgun forwards requests to (e.g. your Forgejo instance)
shotgun serve --mappings mappings.toml --target-url https://forgejo.example.com

# 4. Now clients call Shotgun using the GitHub API shape — it translates on the fly
curl http://localhost:8080/repos/owner/repo   # Shotgun calls https://forgejo.example.com/api/v1/repos/owner/repo

When specs change, re-diff without losing your edits:

shotgun sync --source github-api-v2.json --target forgejo-api-v2.json --mappings mappings.toml

Check a mapping file for problems:

shotgun validate --mappings mappings.toml

How matching works

Matching/rewriting is deterministic.

  • Endpoints are matched by normalized path ({param} names are ignored) + HTTP method, then by operationId. Everything else is left unmapped for you.
  • Fields are matched by exact name. Same name + compatible type = auto-mapped. Same name + incompatible type = flagged. Fields on only one side become defaults or drops — the todo list for adding renames.

Nested schemas (e.g. a User inside a Repository) are diffed once and reused everywhere they appear.

The mapping file

What to rewrite is cached in a mappings.toml file.

TOML syntax note: [[endpoints]] (double brackets) starts a new entry in a list — each one defines a separate endpoint. [endpoints.response.renames] (single brackets) is a sub-section of the most recent [[endpoints]] above it, not a new endpoint.

# --- Provenance ---
# Auto-generated by `shotgun init`; records which specs produced this file.

[meta]
source_spec = "github-api.json"       # path to the source OpenAPI spec
target_spec = "forgejo-api.json"      # path to the target OpenAPI spec
generated_at = "2026-08-19T01:01:13Z" # when this file was first generated
shotgun_version = "0.1.0"

# --- Global settings ---

[settings]
target_base_path = "/api/v1"           # prepended to every target path
source_base_path = "/api/v3"           # stripped from the incoming request path before matching --
                                        # for clients that inject a fixed prefix that isn't part of
                                        # the API's logical shape (e.g. the `gh` CLI always requests
                                        # /api/v3/... for any host that isn't literally github.com)
unmapped_endpoint_behavior = "reject"   # "reject" returns 501; "passthrough" forwards as-is
unmapped_field_behavior = "passthrough" # "passthrough" keeps unknown fields;
                                        # "drop" removes all; "drop_unknown" removes unlisted

# Pagination translation between source and target conventions.
[settings.pagination]
source_style = "link_header"  # how the source API paginates
target_style = "link_header"  # how the target API paginates
rewrite_link_urls = true      # rewrite pagination URLs in Link headers to point at the proxy

# Maps source pagination query params to target equivalents.
[settings.pagination.param_map]
per_page = "limit"  # source's "per_page" becomes target's "limit"

# --- Endpoints ---
# Each [[endpoints]] entry maps one source route to one target route.

# A fully mapped endpoint with field-level customizations.
[[endpoints]]
source = "GET /repos/{owner}/{repo}/issues/{issue_number}"  # the route clients call (GitHub shape)
target = "GET /repos/{owner}/{repo}/issues/{index}"        # the upstream route Shotgun actually calls (Forgejo shape)
edited = true  # true = `shotgun sync` will not overwrite this entry on re-diff

# Maps source path parameters to target path parameters.
[endpoints.path_params]
issue_number = "index"  # GitHub's {issue_number} becomes Forgejo's {index}

# Target-only fields to strip from proxied responses.
# Source-API clients don't expect these, so they're hidden.
[endpoints.response]
drops = ["due_date"]

# Field renames in the response body: source_name = "target_name".
# Shotgun never auto-generates these — every rename is a human decision.
[endpoints.response.renames]
number = "index"

# Default values for source-only fields (not present in target responses).
# Shotgun synthesizes these so clients always see the fields they expect.
[endpoints.response.defaults]
draft = false    # boolean fields get false
labels = []      # array fields get []

# Fields with the same name but incompatible types across source and target.
# Auto-diff flags these for human review — they're left untouched at runtime.
[endpoints.response.type_conflicts]
reactions = "object vs array"  # GitHub nests counts in an object, Forgejo uses an array

# Nested objects that should use a reusable [[schemas]] mapping.
# "path" is the JSON key in the response; "schema_map" names the schema below.
[[endpoints.response.nested]]
path = "user"
schema_map = "User"

# Listing endpoint with query param remapping.
[[endpoints]]
source = "GET /repos/{owner}/{repo}/issues"
target = "GET /repos/{owner}/{repo}/issues"
edited = true

# Maps source query parameters to target query parameters.
[endpoints.query_params]
per_page = "limit"
sort = "type"

# Unmapped endpoint — no target equivalent exists.
# Left with an empty target; returns 501 at runtime (when unmapped_endpoint_behavior = "reject").
[[endpoints]]
source = "POST /repos/{owner}/{repo}/issues/{issue_number}/lock"
target = ""
note = "Forgejo has no issue locking API"  # free-text annotation

# Method changes (PUT → PATCH) are supported too.
[[endpoints]]
source = "PUT /repos/{owner}/{repo}/issues/{issue_number}"
target = "PATCH /repos/{owner}/{repo}/issues/{index}"
edited = true

[endpoints.path_params]
issue_number = "index"

# Request body renames — same syntax as response renames.
[endpoints.request.renames]
number = "index"

# --- Reusable schemas ---
# Named field maps for types that appear in multiple endpoints.
# Reference them from [[endpoints.response.nested]] or [[endpoints.request.nested]]
# so the mapping is defined once and applied everywhere the type appears.

[[schemas]]
name = "User"    # the schema name, referenced by schema_map in nested entries
edited = true    # same as endpoints — protects from sync overwrite

# Same renames / defaults / drops as endpoint field mappings.
[schemas.renames]
login = "username"

[schemas.defaults]
site_admin = false

Key concepts:

  • [meta] records which specs produced this file and when.
  • [settings] controls the target base path, what happens to unmapped endpoints/fields, pagination translation, and synthesized response headers.
  • Renames map source field names to target field names. Shotgun never writes these — every rename is something a human added.
  • Defaults synthesize source-only fields with a zero value so clients always see them.
  • Drops hide target-only fields that source-API clients don't expect.
  • Type conflicts flag fields with the same name but incompatible types for human review.
  • path_params / query_params / headers rename parameters and headers between source and target.
  • nested delegates a sub-object to a named [[schemas]] entry, so the same field map is reused everywhere that type appears.
  • edited = true marks entries that shotgun sync should leave alone on re-diff.
  • note is a free-text annotation (typically auto-generated for unmapped endpoints).

Status

Working core, not a finished product. OpenAPI 3.0/3.1 and Swagger 2.0 are supported.

License

MIT

Contributors

ThatXliner

19 commits

Languages

HTML

49.2%

Rust

28.5%

CSS

19.1%

Python

2.3%