Generate OpenAPI 3.1 specifications from LinkML schemas
Project description
linkml-openapi
Generate OpenAPI 3.1 specifications from LinkML schemas.
Features
- Converts LinkML classes to OpenAPI component schemas (JSON Schema)
- Generates CRUD endpoints with path/query parameters
- Supports inheritance via
allOfreferences - Maps LinkML enums, ranges, constraints, and multivalued slots
- Annotation-driven control over resources, paths, operations, path variables, and query parameters
- CLI and Python API
- Registers as a LinkML generator plugin (
linkml.generatorsentry point)
Install
pip install linkml-openapi
Usage
CLI
# Generate OpenAPI YAML from a LinkML schema
gen-openapi schema.yaml > openapi.yaml
# JSON output
gen-openapi schema.yaml -f json > openapi.json
# Custom title, version, server
gen-openapi schema.yaml --api-title "My API" --api-version 2.0.0 --server-url https://api.example.com
# Only generate endpoints for specific classes
gen-openapi schema.yaml --classes Person --classes Address
Python
from linkml_openapi.generator import OpenAPIGenerator
gen = OpenAPIGenerator("schema.yaml", api_title="My API", server_url="https://api.example.com")
yaml_str = gen.serialize(format="yaml")
json_str = gen.serialize(format="json")
Generator options
| Parameter | Type | Default | Description |
|---|---|---|---|
api_title |
str |
schema name | info.title in the spec |
api_version |
str |
"1.0.0" |
info.version in the spec |
server_url |
str |
"http://localhost:8000" |
servers[0].url in the spec |
resource_filter |
list[str] |
None |
Only generate endpoints for these classes |
format |
str |
"yaml" |
Output format: "yaml" or "json" |
openapi_version |
str |
"3.0.3" |
OpenAPI dialect to emit ("3.0.3" or "3.1.0") |
flatten_inheritance |
bool |
False |
Inline parent properties instead of using allOf |
error_schema |
bool |
True |
Synthesize an RFC 7807 Problem schema and reference it from non-2xx responses |
Why default to 3.0.3? Several popular codegens — notably
openapi-generator's Spring server library — still mishandleallOf-based inheritance under OpenAPI 3.1.0, silently producing duplicateFoo_1schemas. 3.0.3 round-trips the same schemas cleanly. Pass--openapi-version 3.1.0to opt into the newer dialect once your downstream tooling is ready.
--flatten-inheritanceinlines every inherited property directly into the subclass schema, so each component is self-contained and there is noallOfat all. Use it for codegens that still trip on inline-schema-inside -allOf, or whenever you prefer denormalized schemas.
Annotations
All openapi.* annotations use LinkML's built-in annotations mechanism and do not require changes to the LinkML metamodel. Annotation values are strings. Boolean-like annotations use "true" / "false".
Schema-level annotations
Placed at the top of the schema, in the same annotations: block that LinkML
uses for schema-wide metadata.
openapi.profile.<name>.<key> — multi-view filtering
A single LinkML schema can drive multiple API surfaces (internal, partner, external) by declaring named profiles, then activating one at generation time. Each profile is encoded as flat dotted annotation tags at the schema level:
annotations:
openapi.profile.external.description: Public surface; PII hidden.
openapi.profile.external.exclude_classes: AuditLog
openapi.profile.external.exclude_slots: internal_notes,pii_email,contributor_id
openapi.profile.partner.description: Authenticated partner organisations.
openapi.profile.partner.exclude_slots: internal_notes
| Key | Value | Effect |
|---|---|---|
description |
string | Tagged into info.description of the generated spec. |
exclude_classes |
comma-separated class names | Removes the class from components.schemas and drops every endpoint emitted for it. Slots whose range is an excluded class are also dropped. |
exclude_slots |
comma-separated slot names | Removes the slot from every class schema (including via is_a inheritance) and from every nested-path / query-param walk. |
include_classes / include_slots |
comma-separated names | Reserved for whitelist semantics; not yet implemented. |
Activate a profile at generation time:
gen-openapi schema.yaml > openapi-internal.yaml # full surface
gen-openapi schema.yaml --profile partner > openapi-partner.yaml
gen-openapi schema.yaml --profile external > openapi-external.yaml
Profile-restricted specs still carry valid x-rdf-class /
x-rdf-property extensions on every slot they do expose — the same
in-memory service can serve different audiences with faithful RDF
graphs from the same data.
Drift detection. A profile that excludes a slot annotated with
openapi.path_variable or openapi.query_param would silently emit
a broken spec — so the generator fails at generation time with the
exact remediation:
ValueError: Profile 'external' excludes slot 'id' on 'Item', but the
slot is annotated with openapi.path_variable. Remove the annotation,
drop the slot from exclude_slots, or exclude the whole class.
Activating a non-declared profile also fails loudly, listing the profiles that are declared.
openapi.error_class
Names a class in the schema to use as the body of every non-2xx response,
replacing the synthesized RFC 7807 Problem. Used when an organisation
already has a standardised error envelope it wants every API to emit.
annotations:
openapi.error_class: ApiError
classes:
ApiError:
attributes:
code: { range: string, required: true }
message: { range: string, required: true }
trace_id: string
When omitted (the default), the generator synthesizes a Problem schema
matching RFC 7807 — Problem Details for HTTP APIs
and references it from every 404 / 422 response.
The named class must exist in the schema; otherwise generation fails with a clear error.
To opt out entirely (today's body-less responses), pass
--no-error-schema on the CLI or error_schema=False to the Python API.
openapi.path_style
URL path-segment convention for the whole spec. Default snake_case
(byte-identical to today). Set to kebab-case to render every
auto-derived class- and slot-driven URL segment with - instead of
_ — the canonical shape for most public REST APIs.
annotations:
openapi.path_style: kebab-case
Applies to:
- Auto-derived class path segments (
DataService→data-services) - Slot-driven nested segments (
/catalogs/{id}/data-services) - Chain-prefix slot segments inside deep paths
(
/orgs/{orgId}/catalogs/{catalogId}/data-services/{distId})
Does not apply to:
- Slot identifiers in the OpenAPI body (JSON property keys stay snake)
- Operation IDs, tags
x-rdf-propertyURIsopenapi.pathoverrides on a class (taken verbatim)openapi.path_segmentoverrides on a slot (taken verbatim)openapi.path_templateURLs (taken verbatim)
The Python API and CLI both expose a path_style / --path-style
override that wins over the schema annotation.
Class-level annotations
Annotations are placed in the annotations block of a class definition.
openapi.resource
Controls whether a class generates REST endpoints.
| Value | Behaviour |
|---|---|
"true" |
Class generates CRUD endpoints |
"false" or omitted |
Class is excluded from endpoint generation |
Resource selection logic:
- If no class in the schema has
openapi.resource, all non-abstract, non-mixin classes with attributes get endpoints (backwards-compatible default). - If any class has
openapi.resource, only classes withopenapi.resource: "true"generate endpoints. This lets you opt in specific classes while excluding the rest. - Mixin classes (
mixin: true) are always excluded regardless of annotations. - The
resource_filterparameter /--classesCLI flag applies as an additional filter on top of annotation-based selection.
classes:
NamedThing:
description: Abstract base - no endpoints generated
slots: [id, name]
Person:
is_a: NamedThing
annotations:
openapi.resource: "true" # This class gets endpoints
openapi.path
Sets a custom URL path segment for the resource's endpoints.
| Value | Example result |
|---|---|
people |
/people, /people/{id} |
org/units |
/org/units, /org/units/{id} |
| omitted | Auto-pluralized snake_case: Person becomes /persons |
Person:
annotations:
openapi.resource: "true"
openapi.path: people # GET /people, GET /people/{id}
openapi.operations
Comma-separated list of CRUD operations to generate. Controls which HTTP methods appear on the collection and item paths.
| Operation | HTTP method | Path | Description |
|---|---|---|---|
list |
GET |
/{path} |
List instances (supports query params) |
create |
POST |
/{path} |
Create a new instance |
read |
GET |
/{path}/{vars} |
Get a single instance by ID |
update |
PUT |
/{path}/{vars} |
Replace an instance |
patch |
PATCH |
/{path}/{vars} |
Partial update via JSON Merge Patch (RFC 7396) |
delete |
DELETE |
/{path}/{vars} |
Delete an instance |
Default when omitted: all CRUD operations except patch (list,create,read,update,delete). PATCH is opt-in.
When patch is included, the generator also emits a <Class>Patch schema in
components.schemas: a flat schema with every induced slot present and
optional, identifier excluded, additionalProperties: false, and
x-rdf-class / x-rdf-property extensions preserved. The PATCH request
body media type is fixed at application/merge-patch+json (RFC 7396 is
JSON-specific); the 200 response uses the class's openapi.media_types as
usual. Multivalued slots replace wholesale per RFC 7396 — that is the
spec's behaviour, not a generator quirk.
Person:
annotations:
openapi.resource: "true"
openapi.operations: "list,read" # Read-only: GET /people + GET /people/{id}
AuditLog:
annotations:
openapi.resource: "true"
openapi.operations: "list" # Collection-only, no item endpoint
Discriminator (polymorphism)
The generator emits an OpenAPI discriminator block on a parent schema
when either signal is present:
-
LinkML-native — a slot with
designates_type: true. The slot becomes the discriminator field; concrete subclass instances default to the class name (LinkML's own behaviour).classes: Animal: abstract: true attributes: species: designates_type: true range: string Dog: { is_a: Animal } Cat: { is_a: Animal }
-
Existing-system override —
openapi.discriminator: <field>on the parent picks (or synthesizes) the field, andopenapi.type_value: <string>on each subclass pins the wire value. Use this when you're adopting linkml-openapi against an existing API surface that already has a fixed field name and fixed values you can't change.classes: Product: abstract: true annotations: openapi.discriminator: kind # synthesizes the field if needed attributes: sku: { identifier: true, range: string, required: true } Book: is_a: Product annotations: openapi.type_value: BOOK # not "Book" attributes: title: { range: string } Vinyl: is_a: Product annotations: openapi.type_value: VINYL
produces
Product: properties: kind: { type: string, enum: [BOOK, VINYL] } required: [sku, kind] discriminator: propertyName: kind mapping: BOOK: '#/components/schemas/Book' VINYL: '#/components/schemas/Vinyl' Book: allOf: - $ref: '#/components/schemas/Product' - properties: kind: { type: string, enum: [BOOK], default: BOOK } required: [kind]
| Annotation | Where | Purpose |
|---|---|---|
openapi.discriminator: <field> |
parent class | Pick or synthesise the discriminator field. Errors if the class also has designates_type. |
openapi.type_value: <string> |
concrete subclass | Override the default wire value (class name) for an existing-system match. |
Validation:
designates_type: trueandopenapi.discriminatoron the same class → generation error (they say the same thing two ways).- Two subclasses with the same
openapi.type_valuein one discriminator group → generation error. - Mixins are not part of the polymorphic mapping (they're trait composition, not subtyping).
Polymorphic endpoints fall out automatically: an abstract parent with
openapi.resource: "true" gets standard CRUD paths whose request /
response schemas $ref the parent — and the discriminator block on
the parent does the polymorphic dispatch at codegen / runtime.
openapi.media_types
Comma-separated list of media types each operation generated for the class should advertise on its responses and request bodies.
| Value | Example result |
|---|---|
"application/json" |
JSON only (default when omitted) |
"application/json,application/ld+json,text/turtle,application/rdf+xml" |
Every listed type appears under responses[*].content and requestBody.content |
The first listed type stays the default. Each operation's response (and the
request body, on POST / PUT) gets one content entry per media type, all
referencing the same component schema. Use this for RDF-shaped APIs (JSON-LD,
Turtle, RDF/XML) or any other content negotiation surface (CSV, NDJSON,
XML, …) — it removes the need for a postprocessor that fans out the content
blocks by hand.
Catalog:
annotations:
openapi.resource: "true"
openapi.path: catalogs
openapi.media_types: "application/json,application/ld+json,text/turtle,application/rdf+xml"
x-rdf-class / x-rdf-property extensions
The generator propagates LinkML's class_uri and slot_uri into the OpenAPI
output as x- extensions. CURIEs are expanded against the schema's prefixes
map; absolute IRIs are passed through verbatim. No annotation is needed —
this is automatic for any schema that already declares URIs.
prefixes:
schema: http://schema.org/
classes:
Person:
class_uri: schema:Person
attributes:
email:
slot_uri: schema:email
produces:
components:
schemas:
Person:
type: object
x-rdf-class: http://schema.org/Person
properties:
email:
type: string
x-rdf-property: http://schema.org/email
This lets RDF-aware downstream tools (SHACL generators, JSON-LD context builders, Jena/RDF4J mappers) consume the OpenAPI spec directly without needing the original LinkML source.
Nested paths from class-ranged slots
Multivalued slots whose range is another class get nested path
operations automatically — no annotation needed. The shape depends
entirely on what LinkML already says about the slot:
| LinkML signal | Semantics | Nested operations |
|---|---|---|
inlined: true (or target has no identifier) |
Composition — child has no independent identity, lifecycle goes through the parent | full CRUD on /{parent}/{id}/{slot} and /{slot}/{target_id} |
inlined: false (default when target has identifier) |
Reference — child has its own lifecycle, the slot links to it | attach (POST with ResourceLink) on /{parent}/{id}/{slot}, detach (DELETE) on /{slot}/{target_id} |
Composition example — Order.line_items is inline:
classes:
Order:
annotations: { openapi.resource: "true" }
attributes:
id: { identifier: true, range: string, required: true }
line_items:
range: LineItem
multivalued: true
inlined: true
LineItem:
attributes:
line_id: { identifier: true, range: string, required: true }
sku: { range: string }
emits
POST /orders/{id}/line_items body: full LineItem
GET /orders/{id}/line_items list
GET /orders/{id}/line_items/{line_item_id} read
PUT /orders/{id}/line_items/{line_item_id} replace
DELETE /orders/{id}/line_items/{line_item_id} delete
Reference example — Person.addresses links to existing Address resources:
classes:
Person:
annotations: { openapi.resource: "true" }
attributes:
id: { identifier: true, range: string, required: true }
addresses: { range: Address, multivalued: true }
Address:
annotations: { openapi.resource: "true" }
attributes:
id: { identifier: true, range: string, required: true }
emits
GET /persons/{id}/addresses list attached
POST /persons/{id}/addresses attach (body: ResourceLink or array)
DELETE /persons/{id}/addresses/{address_id} detach (Address entity stays)
The shared ResourceLink component is added to components.schemas
only when at least one reference relationship is present. Attach body:
{ "id": "https://example.org/addresses/42" }
or as a batch:
[
{ "id": "https://example.org/addresses/42" },
{ "id": "https://example.org/addresses/43" }
]
The Address resource is mutated via /addresses/{id} — the nested
path manages the link, not the linked entity. Composition is the
opposite: the nested path is how the child is mutated, since it has
no independent flat path.
Opt-out per slot. Some multivalued class-ranged slots aren't
browseable collections — back-references, lookups, relationships
already exposed elsewhere. Suppress nested-path generation for an
individual slot with openapi.nested: "false":
Person:
attributes:
addresses: { range: Address, multivalued: true } # default — nested paths emitted
knows: { range: Person, multivalued: true }
slot_usage:
knows:
annotations:
openapi.nested: "false" # ← /persons/{id}/knows is NOT emitted
The slot still appears in the parent's component schema (so it
serializes / deserializes normally); only the nested-path operations
are skipped. The default remains on — multivalued: true, range: Class
already says "this is a collection," and the API exposes it unless you
say otherwise.
Loud failure — a class with openapi.resource: "true" and item-path
operations (read/update/delete) but no identifier slot raises at
generation time with an exact remediation message.
Inverse direction via LinkML's inverse:
LinkML slots are unidirectional. To get the reverse-direction nested
path without declaring a real slot on the other side, use LinkML's
existing inverse: field:
classes:
Article:
annotations: { openapi.resource: "true" }
attributes:
doi: { identifier: true, range: string, required: true }
reviewers:
range: Reviewer
multivalued: true
inverse: Reviewer.articles # ← Reviewer has no real `articles` slot
Reviewer:
annotations: { openapi.resource: "true" }
attributes:
reviewer_id: { identifier: true, range: string, required: true }
emits both directions:
GET /articles/{doi}/reviewers # forward (real slot)
POST /articles/{doi}/reviewers
DELETE /articles/{doi}/reviewers/{reviewer_id}
GET /reviewers/{reviewer_id}/articles # reverse (synthesised from inverse:)
POST /reviewers/{reviewer_id}/articles
DELETE /reviewers/{reviewer_id}/articles/{article_id}
The synthesised reverse direction is always reference-shaped: it uses
the same ResourceLink attach / detach body as a real reference slot
would. Composition can't be inverted (a composed child has no
independent IRI to reference, so there's nothing to attach to from
the other side).
If both sides declare real slots that point at each other via
inverse:, no synthesis happens — each side emits naturally from its
own slot walk. The inverse: declaration is only the load-bearing
signal when one side wants the path without paying for a real slot.
No name-based inference. Without an inverse: declaration, the
generator never guesses that Article.reviewers implies a path on
Reviewer. That's a parallel-vocabulary trap.
Deep nested item paths via parent chains
When a resource class is reachable via one or more ancestor resource classes (a chain of multivalued relationship slots), the generator emits a deep item path that includes every ancestor's identifier as a URL parameter. This is the canonical shape DCAT3, FHIR, and most catalog-style APIs use:
/catalogs/{catalogId}/datasets/{datasetId}
/catalogs/{catalogId}/datasets/{datasetId}/distributions/{distId}
Each ancestor's identifier shows up as a path parameter — not as a field on the leaf component schema. The leaf's URL surface comes from the relationship graph, not from any foreign-key slot.
openapi.path_id
Renames the URL parameter for a class everywhere it appears in a URL —
its own flat item path, single-level nested item paths pointing to it,
and ancestor segments in any deep chain that passes through it. Default
is <class_snake>_id (e.g. {catalog_id}); set the annotation to
override.
classes:
Catalog:
annotations:
openapi.resource: "true"
openapi.path_id: catalogId # → /catalogs/{catalogId}
attributes:
id: { identifier: true, range: string, required: true }
datasets:
range: Dataset
multivalued: true
Dataset:
annotations:
openapi.resource: "true"
openapi.path_id: datasetId
produces:
/catalogs/{catalogId}
/catalogs/{catalogId}/datasets/{datasetId}
openapi.parent_path
Picks the canonical chain when a leaf class is reachable via multiple
ancestor chains. Format: /-separated hops, each hop either
slot_name (when unambiguous) or ClassName.slot_name (qualifier
required to disambiguate same-named slots on different parents).
classes:
Folder:
annotations: { openapi.resource: "true" }
attributes:
id: { identifier: true, required: true }
tags:
range: Tag
multivalued: true
Bookmark:
annotations: { openapi.resource: "true" }
attributes:
id: { identifier: true, required: true }
tags:
range: Tag
multivalued: true
Tag:
annotations:
openapi.resource: "true"
openapi.parent_path: Folder.tags # /folders/{id}/tags/{id} only
Without the annotation, the generator fails at generation time with
both candidate chains listed (Folder.tags, Bookmark.tags) so the
schema author can pick deliberately.
openapi.nested_only
Drops the flat /<class> collection and /<class>/{id} item paths,
leaving the deep nested URL as the only canonical surface. Useful for
sub-resources that don't make sense outside their parent context — a
Distribution is meaningless without the Dataset it belongs to.
classes:
Distribution:
annotations:
openapi.resource: "true"
openapi.nested_only: "true"
After this, /distributions and /distributions/{id} no longer emit;
the only canonical addresses are
/catalogs/{catalogId}/datasets/{datasetId}/distributions/{distId} and
its single-level forms.
openapi.flat_only
Converse of openapi.nested_only. Drops the deep nested item path
emission for the class while keeping the flat collection + flat item
paths. Single-level nested paths from a parent still emit (those are
about this class as a parent, not as a leaf).
classes:
Tag:
annotations:
openapi.resource: "true"
openapi.flat_only: "true" # /tags + /tags/{id}; no deep chain
Setting both openapi.nested_only and openapi.flat_only on the same
class is a generation error.
openapi.path_template — Layer 4 escape hatch
When the URL is dictated by an external contract that the relationship graph can't express (literal segments, compound keys, version prefixes), hand-author the template and tell the generator which class.identifier slot backs each placeholder:
classes:
ResourceVersion:
annotations:
openapi.resource: "true"
openapi.path_template: "/v2/catalogs/{cId}/resources/by-doi/{doi}/{version}"
openapi.path_param_sources: "cId:Catalog.id,doi:ResourceVersion.doi,version:ResourceVersion.version"
openapi.nested_only: "true" # often paired: only the templated URL is canonical
Each {name} in the template must have a matching name:Class.slot
entry in openapi.path_param_sources. The slot's range drives the
parameter schema, so typed parameters and any RDF metadata still flow
through. Validation:
- Placeholder set must equal source-key set; mismatches raise with both lists named.
- Each
Class.slotsource must resolve; unknown class or slot raises. - When
openapi.path_templateis set,openapi.parent_pathis ignored (template wins).
Operation IDs on templated paths are suffixed _via_template to stay
globally unique alongside any flat-path operations the class still
emits.
Operation IDs on deep paths
Deep paths reuse the leaf's flat-path CRUD builders, so their
operationId collides with the flat versions by default. The
generator suffixes deep operation IDs with _via_<chain> (snake-case
ancestor classes) so the spec stays globally unique:
get_distribution # /distributions/{distId}
get_distribution_via_catalog_dataset
# /catalogs/.../datasets/.../distributions/{distId}
Slot-level annotations
Slot annotations are placed via slot_usage on the class (not on the top-level slot definition). This is because the same slot may serve different roles in different classes.
openapi.format
Override the OpenAPI format string for a slot's emitted schema. Useful
when the LinkML range alone doesn't carry enough information — for example
to mark an integer slot as int64 (large byte sizes, epoch milliseconds,
high-cardinality IDs that overflow Integer) or to mark a string slot
as binary / byte / password.
| Slot range | Without annotation | With openapi.format: int64 |
|---|---|---|
integer |
type: integer |
type: integer, format: int64 |
string |
type: string |
type: string, format: <value> |
slots:
byte_size:
range: integer
annotations:
openapi.format: int64 # avoids 32-bit overflow downstream
avatar:
range: string
annotations:
openapi.format: binary # raw bytes, not text
api_key:
range: string
annotations:
openapi.format: password # Swagger UI redacts
For multivalued slots, the format is applied to the array's items
schema, not the array itself (which has no format in OpenAPI).
The annotation accepts any string; no allow-list is enforced, so vendor formats pass through unchanged.
openapi.path_segment
Per-slot override of the rendered URL segment. Taken verbatim — the
schema-level openapi.path_style doesn't touch a slot that already
declares its segment explicitly. Useful for literal acronyms, dotted
segments, or one-off divergences from the global convention.
classes:
Hub:
slot_usage:
web_resources:
annotations:
openapi.path_segment: "web-resources"
emits /hubs/{id}/web-resources while the slot identifier in the
component schema, operation IDs, query parameters, and
x-rdf-property extensions all keep using web_resources. Pair it
with openapi.path on a class for fully-controlled URL shapes.
openapi.path_variable
Marks a slot as a path variable in the item endpoint URL.
| Value | Behaviour |
|---|---|
"true" |
Slot appears as {slot_name} in the item path; the parameter schema mirrors the slot's range (alias for "iri") |
"iri" |
Same as "true" — preserves any format: uri typing from a uri-range slot |
"slug" |
Slot appears as {slot_name} but the parameter schema is plain string, regardless of the slot's range |
| omitted | Slot is not a path variable |
Use "slug" when the URL segment is a short identifier (main,
uk-population-2026) derived from the resource's IRI rather than the IRI
itself — the body still carries the absolute IRI in the same field. Use
"iri" (or "true") when the URL segment is the full IRI (e.g. behind a
URL-encoding gateway).
When one or more slots are annotated as path variables, they replace the default identifier-based placeholder. Multiple path variables are joined in order: /people/{id}/{version}.
When no slots are annotated as path variables, the generator falls back to the class's identifier slot (or a slot named id) in iri mode.
Person:
annotations:
openapi.resource: "true"
openapi.path: people
slot_usage:
id:
annotations:
openapi.path_variable: "true" # GET /people/{id}, schema mirrors slot range
Catalog:
annotations:
openapi.resource: "true"
openapi.path: catalogs
attributes:
id:
identifier: true
range: uri
slot_usage:
id:
annotations:
openapi.path_variable: slug # GET /catalogs/{id}, schema is plain string
openapi.query_param
Marks a slot as a query parameter on the list operation. Accepts a
comma-separated set of capability tokens:
| Token | Effect |
|---|---|
"true" / "equality" |
?slot=value exact-match filter (today's behaviour) |
"comparable" |
adds ?slot__gte= / ?slot__lte= / ?slot__gt= / ?slot__lt=. Implies equality. |
"sortable" |
slot becomes a valid token in a single ?sort= array parameter. Implies equality. |
| omitted | Slot is not a query parameter |
comparable and sortable imply equality — most APIs that filter by
range or sort by a field also accept exact-match.
Person:
annotations:
openapi.resource: "true"
slot_usage:
name:
annotations:
openapi.query_param: sortable # ?name=Alice and ?sort=name,-name
age:
annotations:
openapi.query_param: comparable,sortable # ?age=, ?age__gte=, ?age__lte=, sort
emits these query params on GET /persons (in addition to limit / offset):
?name= ?age=
?age__gte= ?age__lte= ?age__gt= ?age__lt=
?sort= (array, comma-separated, enum: [name, -name, age, -age])
The ?sort= parameter uses style: form, explode: false, so multiple
sort tokens round-trip as ?sort=name,-age.
Validation:
comparableis only well-defined for ordered ranges (integer,float,double,decimal,date,datetime). Setting it on a string slot warns at generation time — lex comparison is rarely the intent.sortableon a multivalued slot is a generation error — sort order over a set isn't well-defined.
When no slots are annotated with openapi.query_param, the generator
auto-infers equality-only query parameters from all non-multivalued,
non-identifier slots with string, integer, boolean, or enum
ranges (backwards compatible).
For catalog-shaped classes with 30+ slots, that auto-inference produces unusably noisy list endpoints. Three layered annotations let you turn it off at whichever level matches your intent.
openapi.auto_query_params — schema or class level
Defaults to "true". Set to "false" to suppress the auto-inference
entirely; only limit, offset, and explicitly annotated slots emit
as query parameters.
# Schema-level: every class in the spec opts out of auto-inference.
annotations:
openapi.auto_query_params: "false"
classes:
Dataset:
annotations:
openapi.resource: "true" # → /datasets?limit=&offset= only
slot_usage:
title:
annotations: { openapi.query_param: equality }
created:
annotations: { openapi.query_param: comparable,sortable }
Class-level wins over schema-level, so a single class can opt back in when the schema-level default is off:
annotations:
openapi.auto_query_params: "false"
classes:
Tag:
annotations:
openapi.resource: "true"
openapi.auto_query_params: "true" # this class keeps auto-inference
openapi.query_param: "false" — slot level
Removes one slot from auto-inference even when auto is enabled — for oversized strings, free-text descriptions, or fields you never want to filter on:
classes:
Article:
slot_usage:
raw_blob:
annotations:
openapi.query_param: "false" # excluded from /articles?... params
limit and offset pagination parameters are always included on list
endpoints regardless of any of these annotations.
Annotation summary
| Annotation | Level | Values | Default behaviour |
|---|---|---|---|
openapi.resource |
class | "true" / "false" |
All non-abstract, non-mixin classes |
openapi.path |
class | path segment string | Auto-pluralized snake_case of class name |
openapi.operations |
class | comma-separated list | list,create,read,update,delete |
openapi.media_types |
class | comma-separated list | application/json |
openapi.tag |
class | string | Class name (composition-derived ops inherit from the target) |
openapi.path_style |
schema | snake_case / kebab-case |
snake_case |
openapi.auto_query_params |
schema or class | "true" / "false" |
"true" (auto-infer scalar slots) |
openapi.path_id |
class | identifier name | <class_snake>_id (e.g. catalog_id) |
openapi.parent_path |
class | Class.slot/Class.slot/... |
Auto-derive when chain is unambiguous |
openapi.nested_only |
class | "true" / "false" |
Both flat and deep paths emit |
openapi.flat_only |
class | "true" / "false" |
Both flat and deep paths emit (mutually exclusive with nested_only) |
openapi.path_template |
class | URL template with {name} placeholders |
Auto-derived chain |
openapi.path_template_collection |
class | "true" / "false" |
Collection emits when the template ends in /{name} |
openapi.path_param_sources |
class | comma-separated name:Class.slot entries |
(required when path_template is set) |
openapi.path_variable |
slot (via slot_usage) |
"true" |
Identifier slot |
openapi.path_segment |
slot (via slot_usage) |
URL segment string | Slot name with active path-style applied |
openapi.query_param |
slot (via slot_usage) |
"true" / token list / "false" |
Auto-inferred from slot type |
openapi.format |
slot | format string | derived from slot range |
Type Mapping
Slot range values are mapped to OpenAPI schema types for component schemas, path variables, and query parameters:
| LinkML Range | OpenAPI Type | Format |
|---|---|---|
string |
string |
|
integer |
integer |
|
float |
number |
float |
double |
number |
double |
boolean |
boolean |
|
date |
string |
date |
datetime |
string |
date-time |
uri |
string |
uri |
uriorcurie |
string |
uri |
decimal |
number |
|
ncname |
string |
|
nodeidentifier |
string |
uri |
| Class reference | $ref to component schema |
|
| Enum reference | $ref to component schema |
|
| Multivalued slot | array of the above |
Constraints
LinkML slot constraints map to JSON Schema in component schemas:
| LinkML | JSON Schema |
|---|---|
required: true |
In required array |
pattern |
pattern |
minimum_value |
minimum |
maximum_value |
maximum |
identifier: true |
Path parameter (fallback) |
is_a (inheritance) |
allOf with $ref to parent |
multivalued: true |
type: array with items |
description |
description |
Complete Example
id: https://example.org/my-api
name: my_api_schema
title: My API
prefixes:
linkml: https://w3id.org/linkml/
default_range: string
classes:
NamedThing:
abstract: true
description: Abstract base class (no endpoints)
attributes:
id:
identifier: true
range: string
required: true
name:
range: string
required: true
Person:
is_a: NamedThing
description: A person
annotations:
openapi.resource: "true"
openapi.path: people
openapi.operations: "list,read,create"
attributes:
age:
range: integer
minimum_value: 0
maximum_value: 200
email:
range: string
pattern: "^\\S+@\\S+\\.\\S+$"
status:
range: PersonStatus
slot_usage:
id:
annotations:
openapi.path_variable: "true"
name:
annotations:
openapi.query_param: "true"
age:
annotations:
openapi.query_param: "true"
Address:
description: A mailing address
annotations:
openapi.resource: "true"
openapi.path: addresses
openapi.operations: "list,read"
attributes:
id:
identifier: true
range: string
required: true
street:
range: string
city:
range: string
enums:
PersonStatus:
permissible_values:
ALIVE:
DEAD:
UNKNOWN:
This generates:
| Method | Path | Operation | Query params |
|---|---|---|---|
GET |
/people |
List people | ?name=, ?age=, ?limit=, ?offset= |
POST |
/people |
Create person | |
GET |
/people/{id} |
Get person | |
GET |
/addresses |
List addresses | ?limit=, ?offset=, ?street=, ?city= |
GET |
/addresses/{id} |
Get address |
NamedThingis excluded because it is abstract.Personhas onlylist,read,create(noupdate/delete) due toopenapi.operations.Addresshas onlylist,readdue toopenapi.operations.- Person's query params are annotation-driven (
name,age). Address has noopenapi.query_paramannotations, so params are auto-inferred.
Examples
The examples/ directory contains end-to-end examples with LinkML input schemas and their generated OpenAPI output:
| Example | Description |
|---|---|
petstore/ |
Classic API with custom paths, operation limiting, query params, and enums |
bookstore/ |
Inheritance (is_a), multivalued references, and constraints (pattern, minimum_value) |
minimal/ |
Single class with zero annotations — shows auto-inferred endpoints and query params |
Each directory contains a schema.yaml (LinkML input) and openapi.yaml (generated output). Regenerate all outputs with:
bash examples/generate.sh
Development
pip install -e ".[dev]"
pytest tests/ -v
ruff check src/ tests/
ruff format src/ tests/
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file linkml_openapi-0.7.0.tar.gz.
File metadata
- Download URL: linkml_openapi-0.7.0.tar.gz
- Upload date:
- Size: 88.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6bb385ff87c783a2f64ed6cce81ef6d91f90683cadcb280aacc92d47cd1b9846
|
|
| MD5 |
4e20fcdccf86fc6801dde67b173ffcd1
|
|
| BLAKE2b-256 |
26b7866fc123edfcc4cf75e4c2b6b28ca03c3adaf7c702d8d9731d232b89730f
|
Provenance
The following attestation bundles were made for linkml_openapi-0.7.0.tar.gz:
Publisher:
publish.yml on jackhiggs/linkml-openapi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
linkml_openapi-0.7.0.tar.gz -
Subject digest:
6bb385ff87c783a2f64ed6cce81ef6d91f90683cadcb280aacc92d47cd1b9846 - Sigstore transparency entry: 1397992935
- Sigstore integration time:
-
Permalink:
jackhiggs/linkml-openapi@695ac990c97d047ba54a6f7a0476f6ccc7e9f7e1 -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/jackhiggs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@695ac990c97d047ba54a6f7a0476f6ccc7e9f7e1 -
Trigger Event:
release
-
Statement type:
File details
Details for the file linkml_openapi-0.7.0-py3-none-any.whl.
File metadata
- Download URL: linkml_openapi-0.7.0-py3-none-any.whl
- Upload date:
- Size: 47.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bb25f2469a1336dce0ff46461f4d4ee18a51fff3ea3b75e7d2a3187b8348b99d
|
|
| MD5 |
ae49a3405064b9a711d208373d35ec2a
|
|
| BLAKE2b-256 |
5d083a5b2592c1571f1460b2e5d4530fcfbe853d84c356fe23eacd14c1f11cf2
|
Provenance
The following attestation bundles were made for linkml_openapi-0.7.0-py3-none-any.whl:
Publisher:
publish.yml on jackhiggs/linkml-openapi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
linkml_openapi-0.7.0-py3-none-any.whl -
Subject digest:
bb25f2469a1336dce0ff46461f4d4ee18a51fff3ea3b75e7d2a3187b8348b99d - Sigstore transparency entry: 1397992963
- Sigstore integration time:
-
Permalink:
jackhiggs/linkml-openapi@695ac990c97d047ba54a6f7a0476f6ccc7e9f7e1 -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/jackhiggs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@695ac990c97d047ba54a6f7a0476f6ccc7e9f7e1 -
Trigger Event:
release
-
Statement type: