Integration client from an OpenAPI spec¶
Integrating with a third party is, today, manual transcription: you open their
documentation, read field by field, write the equivalent Pydantic schema, choose
the Python name for each field (createdAt → created_at), wire the network aliases so
the payload still matches the wire — and then write another layer just to
assemble the HTTP calls.
The OpenAPI specification already describes all of it formally. So:
+ src/integrations/vendor/__init__.py
+ src/integrations/vendor/client.py
+ src/integrations/vendor/schemas.py
4 schema(s), 12 operation(s).
Done. Typed schemas with their metadata filled in, and a typed HTTP client on top of them. 🚀
Why this matters¶
Three problems with manual transcription:
- Cost proportional to the API's size. 40 endpoints and 60 models is a whole afternoon of mechanical work.
- It gets things wrong, and it rots. An optional field transcribed as
required, a forgotten
alias, an enum copied incompletely — all fail only at runtime, against the third party, often only in production. - The documentation is lost. The spec describes every field with a description, a format and an example. None of it survives transcription: the schema lands in your repository as a list of names and types.
Item 3 is what the generator attacks hardest: every Field carries the
specification's title / description / examples, so the generated module
is the integration's documentation — and it outlives the third party changing
or retiring their docs site.
What you get¶
src/integrations/vendor/
├── __init__.py re-exports the client and DEFAULT_BASE_URL
├── schemas.py one class per component, metadata filled in
└── client.py one async method per operation
Why its own package instead of src/schemas/
A third-party integration is an outbound adapter, not your service's DTO
layer. Dropping 60 generated schemas into src/schemas/ would collide with
the hand-written ones and pollute that package's __init__.py. A separate,
fully-generated directory is safe to regenerate — nothing in it is ever
hand-edited.
Need it elsewhere? --out src/vendor/vendor.
schemas.py¶
For this slice of specification:
{
"Customer": {
"type": "object",
"description": "A billable customer account.",
"required": ["id", "emailAddress"],
"properties": {
"id": {"type": "string", "format": "uuid", "description": "Server-assigned id."},
"emailAddress": {"type": "string", "format": "email", "title": "Email",
"description": "Primary contact email.", "example": "ana@example.com"},
"createdAt": {"type": "string", "format": "date-time"},
"tags": {"type": "array", "items": {"type": "string"}},
"class": {"type": "string", "description": "Reserved-word field name."}
}
}
}
you get this:
from datetime import datetime
from uuid import UUID
from pydantic import ConfigDict, EmailStr, Field
from tempest_fastapi_sdk import BaseSchema
class Customer(BaseSchema):
"""A billable customer account.
Attributes:
id (UUID): Server-assigned id.
email_address (EmailStr): Primary contact email.
created_at (datetime | None): Undocumented in the spec.
tags (list[str]): Undocumented in the spec.
class_ (str | None): Reserved-word field name.
"""
model_config = ConfigDict(populate_by_name=True)
id: UUID = Field(description="Server-assigned id.")
email_address: EmailStr = Field(
validation_alias="emailAddress",
serialization_alias="emailAddress",
title="Email",
description="Primary contact email.",
examples=["ana@example.com"],
)
created_at: datetime | None = Field(
validation_alias="createdAt",
serialization_alias="createdAt",
default=None,
)
tags: list[str] = Field(default_factory=list)
class_: str | None = Field(
validation_alias="class",
serialization_alias="class",
description="Reserved-word field name.",
default=None,
)
Five things happening there:
- Python names + wire aliases.
emailAddress→email_address, with the wire name preserved invalidation_aliasandserialization_alias.populate_by_name=Truemakes the schema accept both on input;model_dump(by_alias=True)gives back the wire shape. - Reserved word resolved.
class→class_, aliases intact.
Two aliases, never alias=
The generator writes the wire name twice -- validation_alias to read,
serialization_alias to write -- and never the single alias. The
difference does not show at runtime, and it does show in your consumer's
editor: with alias, pyright renames the synthesized __init__ parameter
and rejects UserSchema(email_address=...), asking for emailAddress.
formatbecomes a rich type.uuid→UUID,date-time→datetime,email→EmailStr.- An optional collection is an empty list, never
list[X] | None— the project rule: "no matches" is an empty list, not a missing value. - Metadata filled in, and nothing invented: a field with no
descriptionupstream gets none (the docstring saysUndocumented in the spec.).
client.py¶
from tempest_fastapi_sdk import HTTPClient
from src.integrations.billing import Customer, CustomerStatus
class VendorClient:
"""Client for Billing API (version 2.1.0)."""
def __init__(self, client: HTTPClient) -> None:
"""Initialize the client.
Args:
client (HTTPClient): The transport to issue requests through.
"""
self._client: HTTPClient = client
async def list_customers(
self,
*,
page_size: int | None = None,
status: CustomerStatus | None = None,
) -> list[Customer]:
"""List customers.
Args:
page_size (int | None): Rows per page. Omitted from the query when None.
status (CustomerStatus | None): The status value. Omitted when None.
Returns:
list[Customer]: The 200 response body, validated.
Raises:
httpx.HTTPStatusError: For any non-2xx response. The specification
documents 401.
"""
The client takes an HTTPClient by injection — it never
builds one. So the retry policy, backoff, circuit breaker, timeout and
credentials all stay yours:
# src/api/dependencies/resources.py
from tempest_fastapi_sdk import HTTPClient
from src.core.settings import settings
from src.integrations.vendor import DEFAULT_BASE_URL, VendorClient
vendor_http: HTTPClient = HTTPClient(
base_url=DEFAULT_BASE_URL,
default_headers={"Authorization": f"Bearer {settings.VENDOR_TOKEN}"},
timeout=15.0,
)
vendor: VendorClient = VendorClient(vendor_http)
And using it:
import asyncio
from tempest_fastapi_sdk import HTTPClient
from src.integrations.billing import CustomerStatus, TerceiroClient
vendor = TerceiroClient(HTTPClient(base_url="https://api.parceiro.com"))
async def main() -> None:
"""Run this example."""
customers = await vendor.list_customers(
page_size=25,
status=CustomerStatus.PAST_DUE,
)
for customer in customers:
print(customer.email_address, customer.created_at)
asyncio.run(main())
Testable without a network
Because the transport is injected, an httpx.MockTransport covers the whole
integration in your tests:
import httpx
from tempest_fastapi_sdk import HTTPClient
from src.integrations.vendor import DEFAULT_BASE_URL, VendorClient
def handler(request: httpx.Request) -> httpx.Response:
"""Answer the call without leaving the machine."""
return httpx.Response(200, json=[])
async def test_list() -> None:
"""Exercise the generated client against a fake transport."""
http = HTTPClient(
base_url=DEFAULT_BASE_URL,
transport=httpx.MockTransport(handler),
)
async with http:
assert await VendorClient(http).list_customers() == []
The generated client needs the [http] extra
HTTPClient raises ImportError without it.
uv add "tempest-fastapi-sdk[http]".
A declared header becomes a call argument¶
A header the specification declares on the operation is a per-request value, so the generator emits it as a keyword-only argument rather than as a client default:
import uuid
from src.integrations.vendor import VendorClient
from src.integrations.vendor.schemas import PaymentRequest
async def charge(client: VendorClient) -> None:
"""Charge once, with a key that makes the retry safe.
Args:
client (VendorClient): The generated client.
"""
await client.create_payment(
body=PaymentRequest(transaction_amount=19.9),
x_idempotency_key=uuid.uuid4(),
)
Why not default_headers
The generator used to drop these parameters with the note "pass it via
HTTPClient default_headers". For most headers that would be merely
inconvenient; for an idempotency key it is a defect: default_headers
sends the same value on every request, so the second charge would be
deduplicated onto the first and your customer would see one payment where
they made two.
A header that holds for the whole connection — Authorization, a fixed
x-platform-id for your app — still belongs in default_headers. What
changed is who decides: you, per call, instead of the generator deciding
for you.
None sends no header at all
An optional header you do not pass simply does not reach the wire. An
empty X-Idempotency-Key: is not the same as an absent one — a provider
that validates the header would answer 400 on every call that did not opt
in.
cookie is now the only location still dropped, with a note: a cookie is
connection state, not a per-call value.
Options¶
| Option | Effect |
|---|---|
<spec> (argument) |
URL (http(s)://) or path of the specification |
--name / -n |
Integration name — becomes the directory and the class prefix. Defaults to a slug of info.title |
--out / -o |
Destination. Defaults to <src|app>/integrations/<name>/ |
--header / -H |
Header for fetching the spec ("Authorization: Bearer ..."). Repeatable |
--path / -p |
Project root used to resolve the default destination |
--schemas-only |
Do not generate client.py |
--force / -f |
Overwrite what already exists |
--no-format |
Skip the ruff format pass over the result |
A specification behind authentication¶
tempest openapi-client https://api.vendor.com/openapi.json \
--name vendor \
--header "Authorization: Bearer $VENDOR_TOKEN"
A YAML specification¶
Works, but needs the [openapi] extra (PyYAML). JSON needs nothing beyond the
standard library.
Without the extra, the message says exactly that instead of raising a traceback.
Refreshing when the third party versions their API¶
The directory is entirely generated, so regenerating is safe:
The diff is the integration's changelog
Running against an unchanged spec produces a byte-for-byte identical
file (there is a test for it). So any line that shows up in git diff after
a --force is a real change from the third party — a new field, a field
that became required, an enum that gained a value.
OpenAPI coverage¶
What the generator represents, stated:
| Construct | Handling |
|---|---|
type: object + properties |
Class inheriting BaseSchema |
required |
Field with no default; absent → X | None = None |
string/integer/number/boolean |
str/int/float/bool |
format: date-time/date/time |
datetime/date/time |
format: uuid/email/binary/decimal |
UUID/EmailStr/bytes/Decimal |
type: array |
list[T]; not required → Field(default_factory=list) |
String / integer enum |
Subclass of BaseStrEnum / BaseIntEnum |
Internal $ref |
Reference to the generated class, dependency-ordered |
allOf |
Flattened into a single model |
oneOf / anyOf |
A | B; with discriminator, Annotated[..., Field(discriminator=...)] |
nullable: true (3.0) / type: [x, "null"] (3.1) |
X | None |
additionalProperties |
dict[str, T] |
minLength / maximum / pattern / minItems / … |
Field constraints |
| Recursive / mutually recursive | Deferred annotations + model_rebuild() at the end of the module |
path and query parameters |
Typed method arguments |
application/json body and response |
Generated schema |
| 204 / no-content response | None return |
And what is not represented — always with a line in the command's summary, never silently:
| Construct | Reason |
|---|---|
not |
No Python equivalent |
External $ref |
Bundle the spec first (redocly bundle) |
| Swagger 2.0 | Convert to OpenAPI 3 (swagger2openapi) |
cookie parameters |
A cookie is connection state, not a per-call value |
Non-JSON body/response (multipart, octet-stream) |
Out of scope for this iteration |
type with several concrete values |
Not modelled |
It never guesses
The parser's contract: whatever it cannot represent as the spec wrote it
becomes a line in the summary, and each line says what was done
instead — became Any, was skipped, was synthesized:
1 construct(s) could not be modelled as written — each line says what was
generated instead, and the ones with something to mark carry an
`# openapi: unsupported` comment in the output:
- 'cookie' parameter 'sessionHint' skipped (pass it via HTTPClient default_headers)
A wrong schema that looks right is worse than a documented gap.
The gap is marked in the file, not only in the terminal
The summary scrolls out of the terminal. Someone opening schemas.py six
months from now and finding an Any needs the reason right next to
it, so the generator writes a comment above the affected line:
# openapi: unsupported — `not` in ThingWeird rendered as Any (no Python
# equivalent)
weird: Any | None = None
It covers fields, methods (a multipart body, an unmodelled response) and
synthesized parameters. It is greppable on purpose:
grep -rn "openapi: unsupported" src/integrations/ lists everything the
integration lost. A gap with nothing in the file to mark — a dropped
cookie parameter, say — stays summary-only, because there is no line to
comment on.
The generated code passes your gates¶
The emitter produces code that passes ruff check and ruff format --check
before any formatting pass — full annotations, Google-style docstrings on
every module/class/method, imports in isort order, and quotes in the style
ruff format normalizes to (double, save for the case explained
just below).
That is tested, not promised: the suite runs ruff against the raw output
(--no-format). It is worth knowing why — that one assertion caught an
un-imported UUID, an un-imported enum, an over-long docstring line and two
import-ordering mistakes that no assertion about the schemas' shape would have
noticed.
So --no-format (or a machine with no ruff installed) still yields a usable
package. The ruff pass the command runs by default is polish, not correctness.
The spec's text does not break the module¶
The spec's prose ends up in the source: in a docstring, in a title, in a
description, in an enum value. And the third party writes whatever they like
there — quotes, an apostrophe, a backslash, a line break carried over from a
YAML block, a sentence too long for the line. Each of those has produced a
package that did not import, did not lint, or silently changed what the spec
said.
Here it is on a concrete case. This one property carries four traps at once —
quotes in the title, a \# in the description, text too long for the line,
and a wire name starting with a digit:
{
"Charge": {
"type": "object",
"required": ["reference"],
"properties": {
"reference": {
"type": "string",
"title": "The payer's \"reference\"",
"description": "Encode the characters (%, \\#, /) before sending, because the gateway rejects the request and the error it answers with does not say which character was at fault."
},
"2fa": {"type": "boolean"}
}
}
}
And this comes out — code that passes ruff check and ruff format --check
with no formatting pass over it:
from pydantic import ConfigDict, Field
from tempest_fastapi_sdk import BaseSchema
class Charge(BaseSchema):
r"""Schema generated for Charge.
Attributes:
reference (str): Encode the characters (%, \#, /) before sending, because the
gateway rejects the request and the error it answers with does not say which
character was at fault.
field_2fa (bool | None): Undocumented in the spec.
"""
model_config = ConfigDict(populate_by_name=True)
reference: str = Field(
title='The payer\'s "reference"',
description=(
"Encode the characters (%, \\#, /) before sending, because the gateway "
"rejects the request and the error it answers with does not say which "
"character was at fault."
),
)
field_2fa: bool | None = Field(
validation_alias="2fa",
serialization_alias="2fa",
default=None,
)
Four decisions in that output, none of them obvious:
- The docstring became
r""".\#is not a Python escape: without ther, that isW605in the lint and aSyntaxWarningfrom 3.12 on. - The
titlecame out single-quoted, despite the project's double-quote rule. - The
descriptionwas split into adjacent literals rather than left on a long line. 2fabecamefield_2fa, with the wire name in both aliases — covered in the next section.
The middle two share one cause, and it is worth understanding:
Why single quotes, when the project's rule is double
Because ruff format normalizes to whichever escapes less: text with
more " than ' comes out single-quoted. Emitting
title="The payer's \"reference\"" there is correct, readable code — that
fails the consumer's ruff format --check on their very first run. The
generator does not fight the formatter on the other side; it reproduces its
rule.
ruff format never breaks a string
An over-long description survives the format pass intact and blows the
consumer's E501. So the emitter splits — into two or more pieces,
because a lone parenthesized literal is joined straight back onto the long
line. Splitting into one piece is not splitting.
The text returns character for character: concatenating the emitted literals reproduces the original description, whitespace included. Nothing is summarized or truncated.
Summing up what the emitter guarantees for any text the spec carries:
| In the spec | In the generated code |
|---|---|
A backslash (\#, \b, \x41) |
Escaped in the literal, and the docstring becomes r""" |
| Line break, tab, control character | \n / \t / \xNN in the literal |
"quotes" in the text |
A single-quoted literal |
| An over-long description | Split into two or more adjacent literals |
| An over-long enum value | Split the same way, and the member name is shortened — the value, never |
Long names and the line budget¶
The generator synthesizes an inline schema's class name by concatenating
the whole path — PostApiV1DecodeEmvResponseEmvMerchantAccountInformationPix.
Nothing in the spec bounds that length, so the annotation alone can overrun
before any argument is reached.
This is not cosmetic. When the name: Annotation = Field( line overruns,
ruff format wraps the assignment and re-indents the arguments one level
deeper — and every string the emitter had split to fit column 88 comes out
at 92. One defect, two symptoms.
The emitter picks the same shape ruff format would, in the order it tries
them:
| Situation | Shape emitted |
|---|---|
| Head fits | x: T = Field( with arguments at 8 |
| Head overruns, assignment fits | x: T = ( / Field( with arguments at 12 |
| Neither fits | Broken annotation, arguments back at 8 |
| Annotation is a whole subscript | Breaks inside the brackets: list[ / Item / ] |
Synthesized class names are capped at 55 characters. The binding constraint is
not the class statement but the docstring's Attributes: entry: there the
annotation composes around the name (dict[str, Name] | None costs another 18
columns at an indent of 12) and ruff format breaks neither. Measured on the
vendored OpenPix specification: 8 of 373 names truncated, no new collision.
One case has no solution, and the docs do not pretend otherwise
A very long field name next to an annotation that is a single
identifier — x: SomeLongClassName = Field( — has no formatting that
fits. ruff format collapses it, because a bare identifier has nothing to
break on. Only a shorter name helps. Every annotation containing a union or
a subscript does have a stable shape, and that is the one the emitter
produces.
Names and paths the spec gets wrong¶
The same tests cover the other side — when the spec names something Python will not take, or describes a path that does not agree with the parameters it declares:
| In the spec | In the generated code |
|---|---|
A 2fa property |
field_2fa, wire name in both aliases |
transaction and Transaction together |
Transaction and Transaction2 |
A path parameter the template never interpolates |
Dropped, with a note |
| A placeholder no parameter declares | Synthesized as a required str, with a note |
| Path parameters out of order | Reordered by their position in the template |
The less obvious choices
The prefix is field_, not _: a leading underscore makes Pydantic treat
the attribute as private, so the field would vanish from the model
rather than merely be renamed. Transaction_2 is not CapWords and fails the
consumer's N801. A parameter the request never carries is worse than a
missing one: the caller passes an identifier and it is silently dropped on
the floor. And an undeclared placeholder cannot be skipped — the path is
an f-string, so the module would reference a name that does not exist and
would not even import.
Every one of these repairs shows up in the summary
Dropping one parameter and synthesizing another are decisions about the signature you are going to call, so they come out in the command's summary:
2 construct(s) could not be modelled (rendered as Any, marked in the output):
- path parameter 'expand' of '/accounts/{accountId}' is declared but absent
from the path template — skipped, since the value would never reach the request
- path '/receipts/{receiptId}' interpolates 'receiptId', which no parameter
declares — generated as a required str
The synthesized parameter is marked in client.py too, above the method —
the dropped one is not, because no line was left to comment on.
Recap¶
tempest openapi-client <spec> --name Xgeneratessrc/integrations/x/withschemas.py+client.py.- Python names with both aliases for the wire name, and
populate_by_nameso both are accepted on input. - The spec's metadata on every
Field— the generated module is the integration's documentation. Nothing is invented. - The client takes an injected
HTTPClient, so retry / circuit breaker / credentials stay yours, andhttpx.MockTransporttests everything offline. --forceregenerates, and since an unchanged spec yields an identical file, the diff shows exactly what the third party changed.- The spec's prose does not break the module — quotes,
\#, line breaks and over-long text come out as valid literals that passruff format --checkon your side, with the text intact. - A name or path the spec gets wrong is repaired, never guessed — and the repair shows up in the command's summary.
- What is unsupported becomes a line in the summary and an
# openapi: unsupportedcomment in the file, never silence.