Turn any REST API into the shape of another
1
stars
19
commits
HTML
primary language
Aug 21, 2026
updated
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.
cargo install shotgun-proxy
# 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
Matching/rewriting is deterministic.
{param} names are ignored) + HTTP method, then by operationId. Everything else is left unmapped for you.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.
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.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).Working core, not a finished product. OpenAPI 3.0/3.1 and Swagger 2.0 are supported.
MIT
19 commits
HTML
49.2%
Rust
28.5%
CSS
19.1%
Python
2.3%
Turn any REST API into the shape of another
1
stars
19
commits
HTML
primary language
Aug 21, 2026
updated
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.
cargo install shotgun-proxy
# 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
Matching/rewriting is deterministic.
{param} names are ignored) + HTTP method, then by operationId. Everything else is left unmapped for you.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.
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.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).Working core, not a finished product. OpenAPI 3.0/3.1 and Swagger 2.0 are supported.
MIT
19 commits
HTML
49.2%
Rust
28.5%
CSS
19.1%
Python
2.3%