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 -o 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 |
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".
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 |
delete |
DELETE |
/{path}/{vars} |
Delete an instance |
Default when omitted: all five operations (list,create,read,update,delete).
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
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.
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_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.
| Value | Behaviour |
|---|---|
"true" |
Slot appears as an optional query parameter on the collection GET |
| omitted | Slot is not a query parameter |
All annotated query parameters are generated as optional (required: false). The parameter schema type is derived from the slot's range.
When no slots are annotated with openapi.query_param, the generator auto-infers query parameters from all non-multivalued, non-identifier slots with string, integer, boolean, or enum ranges (backwards compatible).
limit and offset pagination parameters are always included on list endpoints regardless of annotations.
Person:
annotations:
openapi.resource: "true"
openapi.path: people
slot_usage:
name:
annotations:
openapi.query_param: "true" # GET /people?name=Alice
age_in_years:
annotations:
openapi.query_param: "true" # GET /people?age_in_years=30
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.path_variable |
slot (via slot_usage) |
"true" |
Identifier slot |
openapi.query_param |
slot (via slot_usage) |
"true" |
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.2.0.tar.gz.
File metadata
- Download URL: linkml_openapi-0.2.0.tar.gz
- Upload date:
- Size: 26.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
678c2a0954a109df716c4465f4599a0773c74c29c4879c8666d7a61004d9a33a
|
|
| MD5 |
b8359ad362441870bde04b2d72823fed
|
|
| BLAKE2b-256 |
79fa2e0d6eee5ec2ac1567fdfcc799a0c28f1f9ae8266e200057a6ec991e1ae7
|
Provenance
The following attestation bundles were made for linkml_openapi-0.2.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.2.0.tar.gz -
Subject digest:
678c2a0954a109df716c4465f4599a0773c74c29c4879c8666d7a61004d9a33a - Sigstore transparency entry: 1384322739
- Sigstore integration time:
-
Permalink:
jackhiggs/linkml-openapi@6842a98609986a6da74f5d084680445ad2c4dbe3 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/jackhiggs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6842a98609986a6da74f5d084680445ad2c4dbe3 -
Trigger Event:
release
-
Statement type:
File details
Details for the file linkml_openapi-0.2.0-py3-none-any.whl.
File metadata
- Download URL: linkml_openapi-0.2.0-py3-none-any.whl
- Upload date:
- Size: 17.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 |
35c519089930ef9e1c5ff16155d0c0cddb4ba66cde8bc90f496a9c9be0b129a5
|
|
| MD5 |
81215208d609b24a8b8725fdcd9749b7
|
|
| BLAKE2b-256 |
95710e48a5e593f08397c44e07bb000f9e7e170138e226ec220d8b704279996a
|
Provenance
The following attestation bundles were made for linkml_openapi-0.2.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.2.0-py3-none-any.whl -
Subject digest:
35c519089930ef9e1c5ff16155d0c0cddb4ba66cde8bc90f496a9c9be0b129a5 - Sigstore transparency entry: 1384322831
- Sigstore integration time:
-
Permalink:
jackhiggs/linkml-openapi@6842a98609986a6da74f5d084680445ad2c4dbe3 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/jackhiggs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6842a98609986a6da74f5d084680445ad2c4dbe3 -
Trigger Event:
release
-
Statement type: