Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions docs-gen/content/docs/tools/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,141 @@ You can call the help for usage reference:
s2dm export jsonschema --help
```

### LinkML

This exporter translates the given GraphQL schema to [LinkML](https://linkml.io/linkml/) schema format (`.yaml`).

#### Key Features

- **Complete GraphQL Type Support**: Handles scalars, objects, input objects, enums, unions, interfaces, and list fields
- **Selection Query**: Use `--selection-query` to filter exported types and fields
- **Root Type Filtering**: Use `--root-type` to export one type and its transitive dependencies
- **Naming Configuration**: Use `--naming-config` to transform names before export
- **Expanded Instance Tags**: Use `--expanded-instances` to replace instance-tag arrays with singular attributes annotated with resolved instance names
- **Explicit Schema Metadata**: Requires LinkML schema identity and namespace inputs (`--id`, `--name`, `--default-prefix`, `--default-prefix-url`)

#### Usage

```bash
s2dm export linkml \
--schema schema.graphql \
--output schema.yaml \
--id https://covesa.global/s2dm \
--name VehicleSchema \
--default-prefix s2dm \
--default-prefix-url https://covesa.global/s2dm
```

#### Required Options

- `--schema, -s`: GraphQL schema file, directory, or URL (repeatable)
- `--output, -o`: Output file path (`.yaml`)
- `--id, -i`: LinkML schema identifier
- `--name, -n`: LinkML schema name
- `--default-prefix`: LinkML default prefix label
- `--default-prefix-url`: Namespace URI for `--default-prefix`

#### Example Transformation

Consider the following GraphQL schema:

```graphql
type Query {
vehicle: Vehicle
}

enum FuelType {
GASOLINE
ELECTRIC
}

type Vehicle {
id: ID!
make: String!
year: Int
fuelType: FuelType
}
```

The LinkML exporter produces:

```yaml
name: VehicleSchema
id: https://covesa.global/s2dm
imports:
- linkml:types
prefixes:
linkml: https://w3id.org/linkml/
s2dm: https://covesa.global/s2dm
default_prefix: s2dm
default_range: string
enums:
FuelType:
permissible_values:
ELECTRIC: {}
GASOLINE: {}
classes:
Vehicle:
attributes:
id:
range: string
required: true
make:
range: string
required: true
year:
range: integer
fuelType:
range: FuelType
```

#### Type Mappings

GraphQL scalar types are mapped to LinkML ranges as follows:

| GraphQL Type | LinkML Range |
| -------------- | -------------- |
| `String` | `string` |
| `Int` | `integer` |
| `Float` | `float` |
| `Boolean` | `boolean` |
| `ID` | `string` |
| `Int8` | `integer` |
| `UInt8` | `integer` |
| `Int16` | `integer` |
| `UInt16` | `integer` |
| `UInt32` | `integer` |
| `Int64` | `integer` |
| `UInt64` | `integer` |

Additional type behavior:

- **Enums**: Converted to LinkML enums with `permissible_values`
- **Lists**: Converted to `multivalued: true`
- **Non-null fields**: Converted to `required: true`
- **Input objects**: Exported as LinkML classes with attributes
- **Unions**: Converted to `any_of` ranges
- **Interfaces**: Converted to abstract classes; implementing types map to `is_a`/`mixins`
- **Custom scalars**: Exported as LinkML types with `base: string`

The exporter skips GraphQL root/introspection types and intermediate expansion types.

#### Directive Support

S2DM directives are converted to LinkML constraints and annotations:

- `@range(min, max)` on output/input fields -> `minimum_value`, `maximum_value`
- `@cardinality(min, max)` on output/input fields -> `minimum_cardinality`, `maximum_cardinality`
- `@noDuplicates` on list fields -> `list_elements_unique: true`
- List item non-null (`[Type!]` / `[Type!]!`) -> `annotations.s2dm_list_item_required: "true"`
- `@metadata(...)` on object/interface/input object types and output/input fields -> LinkML annotations (`s2dm_metadata_*` keys)

You can call the help for usage reference:

```bash
s2dm export linkml --help
```

### Protocol Buffers (Protobuf)

This exporter translates the given GraphQL schema to [Protocol Buffers](https://protobuf.dev/) (`.proto`) format.
Expand Down Expand Up @@ -1688,6 +1823,7 @@ Commands that support `--naming-config`:
- `check constraints` - naming validation
- `compose` - naming transformation
- `export jsonschema` - naming transformation
- `export linkml` - naming transformation
- `export protobuf` - naming transformation
- `export shacl` - naming transformation
- `export vspec` - naming transformation
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"requests>=2.32.0",
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"linkml-runtime>=1.10.0",
]
requires-python = ">=3.11"

Expand Down
3 changes: 2 additions & 1 deletion src/s2dm/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from s2dm import __version__, log
from s2dm.api.errors import ResponseError
from s2dm.api.models.base import ErrorResponse
from s2dm.api.routes import avro, filter, jsonschema, protobuf, query_validate, shacl, validate, vspec
from s2dm.api.routes import avro, filter, jsonschema, linkml, protobuf, query_validate, shacl, validate, vspec

app = FastAPI(
title="S2DM Export API",
Expand Down Expand Up @@ -117,6 +117,7 @@ def health() -> dict[str, str]:
app.include_router(shacl.router, prefix="/api/v1/export", tags=["export"])
app.include_router(vspec.router, prefix="/api/v1/export", tags=["export"])
app.include_router(jsonschema.router, prefix="/api/v1/export", tags=["export"])
app.include_router(linkml.router, prefix="/api/v1/export", tags=["export"])
app.include_router(avro.router, prefix="/api/v1/export", tags=["export"])
app.include_router(protobuf.router, prefix="/api/v1/export", tags=["export"])
app.include_router(filter.router, prefix="/api/v1/schema", tags=["schema"])
Expand Down
24 changes: 24 additions & 0 deletions src/s2dm/api/models/linkml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from pydantic import Field, field_validator

from s2dm.api.models.base import BaseExportRequest
from s2dm.tools.validators import validate_linkml_uri_value


class LinkmlExportRequest(BaseExportRequest):
"""Request model for LinkML export endpoint."""

id: str = Field(description="LinkML schema id (required)", json_schema_extra={"x-cli-flag": "--id"})
name: str = Field(description="LinkML schema name (required)", json_schema_extra={"x-cli-flag": "--name"})
default_prefix: str = Field(
description="LinkML default prefix (required)",
json_schema_extra={"x-cli-flag": "--default-prefix"},
)
default_prefix_url: str = Field(
description="LinkML default prefix URL (required)",
json_schema_extra={"x-cli-flag": "--default-prefix-url"},
)

@field_validator("id", "default_prefix_url")
@classmethod
def validate_linkml_uri_fields(cls, value: str) -> str:
return validate_linkml_uri_value(value)
46 changes: 46 additions & 0 deletions src/s2dm/api/routes/linkml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""LinkML export route."""

from fastapi import APIRouter

from s2dm.api.config import COMMON_RESPONSES
from s2dm.api.models.base import ApiResponse
from s2dm.api.models.linkml import LinkmlExportRequest
from s2dm.api.services.response_service import execute_and_respond
from s2dm.api.services.schema_service import load_and_process_schema_wrapper
from s2dm.exporters.linkml import translate_to_linkml
from s2dm.exporters.utils.schema_loader import check_correct_schema

router = APIRouter(responses=COMMON_RESPONSES)


@router.post(
"/linkml",
response_model=ApiResponse,
openapi_extra={"x-exporter-name": "LinkML", "x-cli-command-name": "linkml"},
)
def export_linkml(request: LinkmlExportRequest) -> ApiResponse:
"""Export GraphQL schema to LinkML."""

def process_request() -> list[str]:
annotated_schema, _ = load_and_process_schema_wrapper(
schemas=request.schemas,
naming_config_input=request.naming_config,
selection_query_input=request.selection_query,
root_type=request.root_type,
expanded_instances=request.expanded_instances,
)

schema_errors = check_correct_schema(annotated_schema.schema)
if schema_errors:
raise ValueError(f"Schema validation failed: {'; '.join(schema_errors)}")

linkml_content = translate_to_linkml(
annotated_schema,
request.id,
request.name,
request.default_prefix,
request.default_prefix_url,
)
return [linkml_content]

return execute_and_respond(executor=process_request, result_format="yaml")
62 changes: 61 additions & 1 deletion src/s2dm/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from s2dm.exporters.avro import translate_to_avro_protocol, translate_to_avro_schema
from s2dm.exporters.id import IDExporter
from s2dm.exporters.jsonschema import translate_to_jsonschema
from s2dm.exporters.linkml import translate_to_linkml
from s2dm.exporters.protobuf import translate_to_protobuf
from s2dm.exporters.shacl import translate_to_shacl
from s2dm.exporters.spec_history import SpecHistoryExporter
Expand Down Expand Up @@ -49,7 +50,7 @@
from s2dm.tools.constraint_checker import ConstraintChecker
from s2dm.tools.diff_parser import DiffChange
from s2dm.tools.graphql_inspector import GraphQLInspector, requires_graphql_inspector
from s2dm.tools.validators import validate_language_tag
from s2dm.tools.validators import validate_language_tag, validate_linkml_uri
from s2dm.units.sync import (
UNITS_META_FILENAME,
UNITS_META_VERSION_KEY,
Expand Down Expand Up @@ -750,6 +751,65 @@ def vspec(
_ = output.write_text(result)


# Export -> linkml
# ----------
@export.command
@schema_option
@selection_query_option()
@output_option
@root_type_option
@naming_config_option
@expanded_instances_option
@click.option(
"--id",
"-i",
"schema_id",
type=str,
callback=validate_linkml_uri,
required=True,
help="LinkML schema id",
)
@click.option("--name", "-n", "schema_name", type=str, required=True, help="LinkML schema name")
@click.option("--default-prefix", type=str, required=True, help="LinkML default prefix")
@click.option(
"--default-prefix-url",
type=str,
callback=validate_linkml_uri,
required=True,
help="LinkML default prefix URL",
)
def linkml(
schemas: list[Path],
selection_query: Path | None,
output: Path,
root_type: str | None,
naming_config: Path | None,
expanded_instances: bool,
schema_id: str,
schema_name: str,
default_prefix: str,
default_prefix_url: str,
) -> None:
"""Generate LinkML schema from a given GraphQL schema."""
annotated_schema, _, _ = load_and_process_schema(
schema_paths=schemas,
naming_config_path=naming_config,
selection_query_path=selection_query,
root_type=root_type,
expanded_instances=expanded_instances,
)
assert_correct_schema(annotated_schema.schema)

result = translate_to_linkml(
annotated_schema,
schema_id,
schema_name,
default_prefix,
default_prefix_url,
)
_ = output.write_text(result)


# Export -> json schema
# ----------
@export.command
Expand Down
5 changes: 5 additions & 0 deletions src/s2dm/exporters/linkml/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""LinkML exporter module for S2DM."""

from .linkml import translate_to_linkml

__all__ = ["translate_to_linkml"]
45 changes: 45 additions & 0 deletions src/s2dm/exporters/linkml/linkml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from s2dm import log
from s2dm.exporters.utils.annotated_schema import AnnotatedSchema

from .transformer import LinkmlTransformer


def transform(
annotated_schema: AnnotatedSchema,
schema_id: str,
schema_name: str,
default_prefix: str,
default_prefix_url: str,
) -> str:
"""Transform an annotated GraphQL schema into LinkML schema YAML."""
log.info(f"Transforming GraphQL schema to LinkML with {len(annotated_schema.schema.type_map)} types")

transformer = LinkmlTransformer(
annotated_schema,
schema_id,
schema_name,
default_prefix,
default_prefix_url,
)
linkml_schema = transformer.transform()

log.info("Successfully converted GraphQL schema to LinkML")

return linkml_schema


def translate_to_linkml(
annotated_schema: AnnotatedSchema,
schema_id: str,
schema_name: str,
default_prefix: str,
default_prefix_url: str,
) -> str:
"""Translate an annotated GraphQL schema into LinkML schema YAML."""
return transform(
annotated_schema,
schema_id,
schema_name,
default_prefix,
default_prefix_url,
)
Loading