linkml-openapi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+
24
+ - name: Install dependencies
25
+ run: pip install -e ".[dev]"
26
+
27
+ - name: Lint
28
+ run: |
29
+ ruff check src/ tests/
30
+ ruff format --check src/ tests/
31
+
32
+ - name: Test
33
+ run: pytest tests/ -v -m "not e2e"
34
+
35
+ e2e:
36
+ runs-on: ubuntu-latest
37
+ needs: test
38
+
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+
42
+ - name: Set up Python 3.12
43
+ uses: actions/setup-python@v5
44
+ with:
45
+ python-version: "3.12"
46
+
47
+ - name: Set up Node.js
48
+ uses: actions/setup-node@v4
49
+ with:
50
+ node-version: "20"
51
+
52
+ - name: Install dependencies
53
+ run: pip install -e ".[dev]"
54
+
55
+ - name: E2E tests
56
+ run: pytest tests/test_e2e_validation.py -v
@@ -0,0 +1,28 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+
21
+ - name: Install build
22
+ run: pip install build
23
+
24
+ - name: Build package
25
+ run: python -m build
26
+
27
+ - name: Publish to PyPI
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Higgs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,391 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkml-openapi
3
+ Version: 0.1.0
4
+ Summary: Generate OpenAPI 3.1 specifications from LinkML schemas
5
+ Project-URL: Homepage, https://github.com/jackhiggs/linkml-openapi
6
+ Project-URL: Repository, https://github.com/jackhiggs/linkml-openapi
7
+ Project-URL: Issues, https://github.com/jackhiggs/linkml-openapi/issues
8
+ Author: Jack Higgs
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: linkml-runtime>=1.7.0
16
+ Requires-Dist: linkml>=1.7.0
17
+ Requires-Dist: openapi-pydantic>=0.5.0
18
+ Requires-Dist: pyyaml>=6.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: openapi-spec-validator>=0.7.0; extra == 'dev'
21
+ Requires-Dist: pytest>=7.0; extra == 'dev'
22
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # linkml-openapi
26
+
27
+ [![CI](https://github.com/jackhiggs/linkml-openapi/actions/workflows/ci.yml/badge.svg)](https://github.com/jackhiggs/linkml-openapi/actions/workflows/ci.yml)
28
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
30
+
31
+ Generate [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) specifications from [LinkML](https://linkml.io/) schemas.
32
+
33
+ ## Features
34
+
35
+ - Converts LinkML classes to OpenAPI component schemas (JSON Schema)
36
+ - Generates CRUD endpoints with path/query parameters
37
+ - Supports inheritance via `allOf` references
38
+ - Maps LinkML enums, ranges, constraints, and multivalued slots
39
+ - Annotation-driven control over resources, paths, operations, path variables, and query parameters
40
+ - CLI and Python API
41
+ - Registers as a LinkML generator plugin (`linkml.generators` entry point)
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install linkml-openapi
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ ### CLI
52
+
53
+ ```bash
54
+ # Generate OpenAPI YAML from a LinkML schema
55
+ gen-openapi schema.yaml > openapi.yaml
56
+
57
+ # JSON output
58
+ gen-openapi schema.yaml -f json -o openapi.json
59
+
60
+ # Custom title, version, server
61
+ gen-openapi schema.yaml --api-title "My API" --api-version 2.0.0 --server-url https://api.example.com
62
+
63
+ # Only generate endpoints for specific classes
64
+ gen-openapi schema.yaml --classes Person --classes Address
65
+ ```
66
+
67
+ ### Python
68
+
69
+ ```python
70
+ from linkml_openapi.generator import OpenAPIGenerator
71
+
72
+ gen = OpenAPIGenerator("schema.yaml", api_title="My API", server_url="https://api.example.com")
73
+ yaml_str = gen.serialize(format="yaml")
74
+ json_str = gen.serialize(format="json")
75
+ ```
76
+
77
+ #### Generator options
78
+
79
+ | Parameter | Type | Default | Description |
80
+ |-----------|------|---------|-------------|
81
+ | `api_title` | `str` | schema name | `info.title` in the spec |
82
+ | `api_version` | `str` | `"1.0.0"` | `info.version` in the spec |
83
+ | `server_url` | `str` | `"http://localhost:8000"` | `servers[0].url` in the spec |
84
+ | `resource_filter` | `list[str]` | `None` | Only generate endpoints for these classes |
85
+ | `format` | `str` | `"yaml"` | Output format: `"yaml"` or `"json"` |
86
+
87
+ ## Annotations
88
+
89
+ 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"`.
90
+
91
+ ### Class-level annotations
92
+
93
+ Annotations are placed in the `annotations` block of a class definition.
94
+
95
+ #### `openapi.resource`
96
+
97
+ Controls whether a class generates REST endpoints.
98
+
99
+ | Value | Behaviour |
100
+ |-------|-----------|
101
+ | `"true"` | Class generates CRUD endpoints |
102
+ | `"false"` or omitted | Class is excluded from endpoint generation |
103
+
104
+ **Resource selection logic:**
105
+
106
+ - If **no class** in the schema has `openapi.resource`, all non-abstract, non-mixin classes with attributes get endpoints (backwards-compatible default).
107
+ - 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.
108
+ - Mixin classes (`mixin: true`) are always excluded regardless of annotations.
109
+ - The `resource_filter` parameter / `--classes` CLI flag applies as an additional filter on top of annotation-based selection.
110
+
111
+ ```yaml
112
+ classes:
113
+ NamedThing:
114
+ description: Abstract base - no endpoints generated
115
+ slots: [id, name]
116
+
117
+ Person:
118
+ is_a: NamedThing
119
+ annotations:
120
+ openapi.resource: "true" # This class gets endpoints
121
+ ```
122
+
123
+ #### `openapi.path`
124
+
125
+ Sets a custom URL path segment for the resource's endpoints.
126
+
127
+ | Value | Example result |
128
+ |-------|----------------|
129
+ | `people` | `/people`, `/people/{id}` |
130
+ | `org/units` | `/org/units`, `/org/units/{id}` |
131
+ | omitted | Auto-pluralized snake_case: `Person` becomes `/persons` |
132
+
133
+ ```yaml
134
+ Person:
135
+ annotations:
136
+ openapi.resource: "true"
137
+ openapi.path: people # GET /people, GET /people/{id}
138
+ ```
139
+
140
+ #### `openapi.operations`
141
+
142
+ Comma-separated list of CRUD operations to generate. Controls which HTTP methods appear on the collection and item paths.
143
+
144
+ | Operation | HTTP method | Path | Description |
145
+ |-----------|-------------|------|-------------|
146
+ | `list` | `GET` | `/{path}` | List instances (supports query params) |
147
+ | `create` | `POST` | `/{path}` | Create a new instance |
148
+ | `read` | `GET` | `/{path}/{vars}` | Get a single instance by ID |
149
+ | `update` | `PUT` | `/{path}/{vars}` | Replace an instance |
150
+ | `delete` | `DELETE` | `/{path}/{vars}` | Delete an instance |
151
+
152
+ Default when omitted: all five operations (`list,create,read,update,delete`).
153
+
154
+ ```yaml
155
+ Person:
156
+ annotations:
157
+ openapi.resource: "true"
158
+ openapi.operations: "list,read" # Read-only: GET /people + GET /people/{id}
159
+ ```
160
+
161
+ ```yaml
162
+ AuditLog:
163
+ annotations:
164
+ openapi.resource: "true"
165
+ openapi.operations: "list" # Collection-only, no item endpoint
166
+ ```
167
+
168
+ ### Slot-level annotations
169
+
170
+ 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.
171
+
172
+ #### `openapi.path_variable`
173
+
174
+ Marks a slot as a path variable in the item endpoint URL.
175
+
176
+ | Value | Behaviour |
177
+ |-------|-----------|
178
+ | `"true"` | Slot appears as `{slot_name}` in the item path |
179
+ | omitted | Slot is not a path variable |
180
+
181
+ 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}`.
182
+
183
+ When no slots are annotated as path variables, the generator falls back to the class's identifier slot (or a slot named `id`).
184
+
185
+ ```yaml
186
+ Person:
187
+ annotations:
188
+ openapi.resource: "true"
189
+ openapi.path: people
190
+ slot_usage:
191
+ id:
192
+ annotations:
193
+ openapi.path_variable: "true" # GET /people/{id}
194
+ ```
195
+
196
+ #### `openapi.query_param`
197
+
198
+ Marks a slot as a query parameter on the `list` operation.
199
+
200
+ | Value | Behaviour |
201
+ |-------|-----------|
202
+ | `"true"` | Slot appears as an optional query parameter on the collection `GET` |
203
+ | omitted | Slot is not a query parameter |
204
+
205
+ All annotated query parameters are generated as optional (`required: false`). The parameter schema type is derived from the slot's `range`.
206
+
207
+ 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).
208
+
209
+ `limit` and `offset` pagination parameters are always included on list endpoints regardless of annotations.
210
+
211
+ ```yaml
212
+ Person:
213
+ annotations:
214
+ openapi.resource: "true"
215
+ openapi.path: people
216
+ slot_usage:
217
+ name:
218
+ annotations:
219
+ openapi.query_param: "true" # GET /people?name=Alice
220
+ age_in_years:
221
+ annotations:
222
+ openapi.query_param: "true" # GET /people?age_in_years=30
223
+ ```
224
+
225
+ ### Annotation summary
226
+
227
+ | Annotation | Level | Values | Default behaviour |
228
+ |------------|-------|--------|-------------------|
229
+ | `openapi.resource` | class | `"true"` / `"false"` | All non-abstract, non-mixin classes |
230
+ | `openapi.path` | class | path segment string | Auto-pluralized snake_case of class name |
231
+ | `openapi.operations` | class | comma-separated list | `list,create,read,update,delete` |
232
+ | `openapi.path_variable` | slot (via `slot_usage`) | `"true"` | Identifier slot |
233
+ | `openapi.query_param` | slot (via `slot_usage`) | `"true"` | Auto-inferred from slot type |
234
+
235
+ ## Type Mapping
236
+
237
+ Slot `range` values are mapped to OpenAPI schema types for component schemas, path variables, and query parameters:
238
+
239
+ | LinkML Range | OpenAPI Type | Format |
240
+ |-------------|-------------|--------|
241
+ | `string` | `string` | |
242
+ | `integer` | `integer` | |
243
+ | `float` | `number` | `float` |
244
+ | `double` | `number` | `double` |
245
+ | `boolean` | `boolean` | |
246
+ | `date` | `string` | `date` |
247
+ | `datetime` | `string` | `date-time` |
248
+ | `uri` | `string` | `uri` |
249
+ | `uriorcurie` | `string` | `uri` |
250
+ | `decimal` | `number` | |
251
+ | `ncname` | `string` | |
252
+ | `nodeidentifier` | `string` | `uri` |
253
+ | Class reference | `$ref` to component schema | |
254
+ | Enum reference | `$ref` to component schema | |
255
+ | Multivalued slot | `array` of the above | |
256
+
257
+ ## Constraints
258
+
259
+ LinkML slot constraints map to JSON Schema in component schemas:
260
+
261
+ | LinkML | JSON Schema |
262
+ |--------|------------|
263
+ | `required: true` | In `required` array |
264
+ | `pattern` | `pattern` |
265
+ | `minimum_value` | `minimum` |
266
+ | `maximum_value` | `maximum` |
267
+ | `identifier: true` | Path parameter (fallback) |
268
+ | `is_a` (inheritance) | `allOf` with `$ref` to parent |
269
+ | `multivalued: true` | `type: array` with `items` |
270
+ | `description` | `description` |
271
+
272
+ ## Complete Example
273
+
274
+ ```yaml
275
+ id: https://example.org/my-api
276
+ name: my_api_schema
277
+ title: My API
278
+
279
+ prefixes:
280
+ linkml: https://w3id.org/linkml/
281
+
282
+ default_range: string
283
+
284
+ classes:
285
+ NamedThing:
286
+ abstract: true
287
+ description: Abstract base class (no endpoints)
288
+ attributes:
289
+ id:
290
+ identifier: true
291
+ range: string
292
+ required: true
293
+ name:
294
+ range: string
295
+ required: true
296
+
297
+ Person:
298
+ is_a: NamedThing
299
+ description: A person
300
+ annotations:
301
+ openapi.resource: "true"
302
+ openapi.path: people
303
+ openapi.operations: "list,read,create"
304
+ attributes:
305
+ age:
306
+ range: integer
307
+ minimum_value: 0
308
+ maximum_value: 200
309
+ email:
310
+ range: string
311
+ pattern: "^\\S+@\\S+\\.\\S+$"
312
+ status:
313
+ range: PersonStatus
314
+ slot_usage:
315
+ id:
316
+ annotations:
317
+ openapi.path_variable: "true"
318
+ name:
319
+ annotations:
320
+ openapi.query_param: "true"
321
+ age:
322
+ annotations:
323
+ openapi.query_param: "true"
324
+
325
+ Address:
326
+ description: A mailing address
327
+ annotations:
328
+ openapi.resource: "true"
329
+ openapi.path: addresses
330
+ openapi.operations: "list,read"
331
+ attributes:
332
+ id:
333
+ identifier: true
334
+ range: string
335
+ required: true
336
+ street:
337
+ range: string
338
+ city:
339
+ range: string
340
+
341
+ enums:
342
+ PersonStatus:
343
+ permissible_values:
344
+ ALIVE:
345
+ DEAD:
346
+ UNKNOWN:
347
+ ```
348
+
349
+ This generates:
350
+
351
+ | Method | Path | Operation | Query params |
352
+ |--------|------|-----------|--------------|
353
+ | `GET` | `/people` | List people | `?name=`, `?age=`, `?limit=`, `?offset=` |
354
+ | `POST` | `/people` | Create person | |
355
+ | `GET` | `/people/{id}` | Get person | |
356
+ | `GET` | `/addresses` | List addresses | `?limit=`, `?offset=`, `?street=`, `?city=` |
357
+ | `GET` | `/addresses/{id}` | Get address | |
358
+
359
+ - `NamedThing` is excluded because it is abstract.
360
+ - `Person` has only `list`, `read`, `create` (no `update`/`delete`) due to `openapi.operations`.
361
+ - `Address` has only `list`, `read` due to `openapi.operations`.
362
+ - Person's query params are annotation-driven (`name`, `age`). Address has no `openapi.query_param` annotations, so params are auto-inferred.
363
+
364
+ ## Examples
365
+
366
+ The `examples/` directory contains end-to-end examples with LinkML input schemas and their generated OpenAPI output:
367
+
368
+ | Example | Description |
369
+ |---------|-------------|
370
+ | [`petstore/`](examples/petstore/) | Classic API with custom paths, operation limiting, query params, and enums |
371
+ | [`bookstore/`](examples/bookstore/) | Inheritance (`is_a`), multivalued references, and constraints (`pattern`, `minimum_value`) |
372
+ | [`minimal/`](examples/minimal/) | Single class with zero annotations — shows auto-inferred endpoints and query params |
373
+
374
+ Each directory contains a `schema.yaml` (LinkML input) and `openapi.yaml` (generated output). Regenerate all outputs with:
375
+
376
+ ```bash
377
+ bash examples/generate.sh
378
+ ```
379
+
380
+ ## Development
381
+
382
+ ```bash
383
+ pip install -e ".[dev]"
384
+ pytest tests/ -v
385
+ ruff check src/ tests/
386
+ ruff format src/ tests/
387
+ ```
388
+
389
+ ## License
390
+
391
+ MIT