Skip to content

GraphQL API

Mosaic can serve an autogenerated GraphQL API alongside the REST API. The GraphQL schema — object types, enums, relationship fields, queries, and mutations — is generated at startup from your deployment's LinkML schema, so it always matches your data model. No per-entity GraphQL code is ever written.

Like the REST API, the GraphQL transport is a thin wrapper: every resolver delegates to the same MosaicClient the SDK, REST, and TUI use, against the same storage.

Installation

GraphQL support ships in the optional graphql extra:

pip install 'datahelix-mosaic[graphql]'

(It is also included in datahelix-mosaic[all].)

Serving

mosaic serve --graphql                 # auto-detect config in the cwd
mosaic serve --graphql --config mosaic.yaml
mosaic serve --graphql --graphql-max-depth 6   # tighten the query-depth limit

The GraphQL endpoint mounts at http://host:port/graphql on the same server as the REST routes. Opening it in a browser (GET) serves the GraphiQL IDE; executing operations (POST) requires the same Authorization: Bearer <token> header as the REST API.

The deployment must be schema-backed: your config's schema_path (or --config) provides the LinkML schema the GraphQL surface is generated from. Without one, --graphql fails at startup with a configuration error.

Embedding

from mosaic.core.factory import create_client_from_config, load_config_autodetect
from mosaic.serve import create_default_app

config = load_config_autodetect("mosaic.yaml")
client = create_client_from_config(config)
app = create_default_app(client, graphql=True, graphql_max_query_depth=10)

What gets generated

For each concrete entity class in your schema (say Sample), Mosaic generates:

Surface Example
Object type type Sample with every slot as a typed field
Page type type SamplePage { items, total, limit, offset }
Input types input SampleCreateInput, input SampleUpdateInput
Queries sample(id), samples(filters, filterMode, limit, offset), searchSamples(q, limit, offset)
Mutations createSample, updateSample, setSampleAvailability, setSampleAvailabilityBulk, supersedeSample

LinkML enums become GraphQL enums. Field names are camelCased (volume_mlvolumeMl); write payloads are mapped back to slot names before they reach the SDK.

A list query's filters argument accepts either spelling for its field — the LinkML slot name (volume_ml, as listed by hippoSchema) or the camelCased field name the type exposes (volumeMl). A field that matches neither is an error (extensions.code: UNKNOWN_FILTER_FIELD) rather than an empty page, as are the two kinds of name that look filterable but cannot be: read-time computed fields (createdAt, updatedAt, supersededBy — for temporal queries use asOf) and multivalued references, which are stored as relationship edges rather than columns (use relatedTo). Both report extensions.code: UNFILTERABLE_FIELD.

The exposed class set is decided by Mosaic's shared type model (mosaic.core.schema_typing) — the same model behind the typed Python SDK — so the two surfaces never drift. Framework classes (Entity, ProvenanceRecord, Process, Validator, ReferenceLoader) are never exposed; ExternalID is.

Relationships traverse the graph

A class-ranged slot generates two fields: the raw stored UUID and a resolved field that walks the relationship.

{
  samples {
    items {
      name
      donorId          # raw stored UUID
      donor {          # resolved Donor — one batched query, no N+1
        name
        sex
      }
    }
  }
}

Resolved fields load through a per-request DataLoader: resolving the same target type N times in one request issues a single batched query.

System and temporal fields

Every entity type carries id and isAvailable (stored), plus the read-only computed fields version, createdAt, updatedAt, schemaVersion, createdBy, updatedBy, and supersededBy — derived from the provenance log at read time, never stored on entity rows.

Lifecycle, not deletion

There are no delete mutations. Mosaic never hard-deletes; use availability transitions:

mutation {
  setSampleAvailability(id: "…", isAvailable: false, reason: "depleted") {
    entityId
    isAvailable
  }
}

mutation {
  setSampleAvailabilityBulk(ids: ["…", "…"], isAvailable: false, reason: "audit") {
    total succeeded failed
    failures { entityId error }
  }
}

The bulk form mirrors the REST bulk-availability endpoint: per-record error isolation — one bad id never rolls back its siblings.

Supersession works the same way as in the SDK, with a read-side query for the chain:

mutation { supersedeSample(id: "old", replacementId: "new", reason: "remeasured") { entityId supersededBy } }

query { supersededBy(id: "old") { entityId supersededBy chain } }

Slots annotated hippo_search in your schema are searchable per type:

{ searchSamples(q: "hippocampus", limit: 20) { id name } }

Schema introspection (the Mosaic kind)

Beyond GraphQL's own __schema, the API exposes Mosaic's domain type model — useful for generic clients that need the LinkML classification:

{
  hippoSchema { name accessorName }
  hippoEntityType(name: "Sample") {
    fields { name kind range role required multivalued targetEntityType enumValues }
    relationships { field targetEntityType }
  }
}

Errors

SDK errors surface as structured GraphQL errors with a machine-readable extensions.code: VALIDATION_FAILED (with the tier-tagged failure envelope when available), NOT_FOUND, ALREADY_SUPERSEDED, AVAILABILITY_CHANGE_FAILED, or INTERNAL_ERROR.

Hardening

Queries nesting deeper than the configured depth limit (default 10) are rejected before execution — relationship fields make unbounded traversals expressible, so the cap is the transport's recursion guard. Introspection is exempt, so GraphiQL works regardless. Configure with --graphql-max-depth / max_query_depth.

Known limitations

  • Offset pagination only — cursor pagination follows the REST roadmap.
  • No schema versioning — the GraphQL schema regenerates from your LinkML schema at startup; breaking LinkML changes break the GraphQL contract the same way they break the typed SDK.
  • Equality filters only (filters + AND/OR composition), mirroring MosaicClient.query.
  • Updates cannot null-out a field — omitted and null input fields are both dropped from the patch.