Skip to main content

Generate OpenAPI 3.1 specifications from LinkML schemas

Project description

linkml-openapi

CI Python 3.11+ License: MIT

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 allOf references
  • 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.generators entry 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 mishandle allOf-based inheritance under OpenAPI 3.1.0, silently producing duplicate Foo_1 schemas. 3.0.3 round-trips the same schemas cleanly. Pass --openapi-version 3.1.0 to opt into the newer dialect once your downstream tooling is ready.

--flatten-inheritance inlines every inherited property directly into the subclass schema, so each component is self-contained and there is no allOf at 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 (DataServicedata-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-property URIs
  • openapi.path overrides on a class (taken verbatim)
  • openapi.path_segment overrides on a slot (taken verbatim)
  • openapi.path_template URLs (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 with openapi.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_filter parameter / --classes CLI 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:

  1. 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 }
    
  2. Existing-system overrideopenapi.discriminator: <field> on the parent picks (or synthesizes) the field, and openapi.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]
      oneOf:
        - $ref: '#/components/schemas/Book'
        - $ref: '#/components/schemas/Vinyl'
      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]
    

The oneOf array is what Swagger UI and openapi-generator's TypeScript / Java / Spring outputs need to offer polymorphic selection — the discriminator alone tells consumers how to interpret a payload but not which subclasses are possible. With --flatten-inheritance, the parent becomes oneOf-only (no properties, type, or required) since each subclass already inlines the parent's slots.

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: true and openapi.discriminator on the same class → generation error (they say the same thing two ways).
  • Two subclasses with the same openapi.type_value in 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.slot source must resolve; unknown class or slot raises.
  • When openapi.path_template is set, openapi.parent_path is 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:

  • comparable is 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.
  • sortable on 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
  • NamedThing is excluded because it is abstract.
  • Person has only list, read, create (no update/delete) due to openapi.operations.
  • Address has only list, read due to openapi.operations.
  • Person's query params are annotation-driven (name, age). Address has no openapi.query_param annotations, 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

linkml_openapi-0.9.0.tar.gz (234.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

linkml_openapi-0.9.0-py3-none-any.whl (78.4 kB view details)

Uploaded Python 3

File details

Details for the file linkml_openapi-0.9.0.tar.gz.

File metadata

  • Download URL: linkml_openapi-0.9.0.tar.gz
  • Upload date:
  • Size: 234.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for linkml_openapi-0.9.0.tar.gz
Algorithm Hash digest
SHA256 670ccec48a510caaee2900d06e3f60ba1c8dcc65c07b6021515afdb6ba8c25ce
MD5 041cdd2aa836f74972d067d7349d0dd3
BLAKE2b-256 0103545f1f46299979f7cc159d144abcac9856b8030ed0c5ea5c7c393fc36daa

See more details on using hashes here.

Provenance

The following attestation bundles were made for linkml_openapi-0.9.0.tar.gz:

Publisher: publish.yml on jackhiggs/linkml-openapi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file linkml_openapi-0.9.0-py3-none-any.whl.

File metadata

  • Download URL: linkml_openapi-0.9.0-py3-none-any.whl
  • Upload date:
  • Size: 78.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for linkml_openapi-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 525f8f53c9975c7299b07b6bbfa60a8e76cbb3d0acda1cf3e1ac007525a7d6a9
MD5 4d6fd3dc563e55dca5c402786949105a
BLAKE2b-256 000709546d2ffeb57e8ff6e25646553f5a33823b4f80fe0cbcd505eedee049f8

See more details on using hashes here.

Provenance

The following attestation bundles were made for linkml_openapi-0.9.0-py3-none-any.whl:

Publisher: publish.yml on jackhiggs/linkml-openapi

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page