Documentation for v2.8.0. Read the latest, v2.12.0.

Developer

sanshain.yaml

Every official client reads the same sanshain.yaml from the project root and turns it into provide, require and catalog calls. This chapter is the file's reference. What each client adds on top (flags, properties, its own caching) is in Clients.

What changed in 2.6.0

The catalog push now also records the system's owner, description, source location and links (the catalog context); the fields Sanshain reads are listed under the catalog phase. The file format is unchanged.

What the file declares#

  1. Connection: the server URL (better set in the environment) and whether to ask for gzip.
  2. Provides: the spec files this project publishes. The version is never configured here; it lives in the spec file. The stability is not configured here either; the build's ga switch decides it.
  3. Requires: the endpoints this project depends on, each pinned to an exact version.
  4. Catalog info (optional): the project's Backstage catalog-info.yaml, whose dependsOn names its peripheral resources and whose owner, description and links introduce the system on its node and page.

A complete example#

A gateway that provides an OpenAPI spec and a proto file, and consumes REST, messaging and gRPC from three other systems:

serviceName: gateway-service
compression: true
catalogInfo: catalog-info.yaml       # optional
# sanshainUrl is omitted: SANSHAIN_URL in the environment

provides:
  - file: src/main/resources/openapi.yaml        # version from info.version
  - file: src/main/resources/gateway.proto       # version from // sanshain-version:
    apiType: proto

requires:
  - serviceName: user-service
    apiType: openapi
    version: 2.3.0
    outputDirectory: target/generated-sources/sanshain/user-service
    endpoints:
      - method: GET
        path: /api/v1/users/{id}

  - serviceName: notification-service
    apiType: asyncapi
    version: 1.1.0
    outputDirectory: target/generated-sources/sanshain/notification-service
    endpoints:
      - method: PUB
        path: notifications.email

  - serviceName: inventory-service
    apiType: proto
    version: 3.0.1
    outputDirectory: target/generated-sources/sanshain/inventory-service
    endpoints:
      - method: GetProduct
        path: inventory.v1.InventoryService

Top-level fields#

Field Required Default Meaning
serviceName yes — The name of this system: the producername of its provides, the consumername of its requires, the systemname of its catalog push.
sanshainUrl no — Base URL of the instance. Prefer SANSHAIN_URL in the environment so the URL is not committed; the environment overrides the file.
compression no false Ask for gzip-compressed answers. Honoured by the Maven (default true there), Go and JavaScript clients; the Rust and Conan clients accept the key and ignore it.
bestEffort no false Turn failed calls into warnings. Read by the Rust, Go, Maven and Conan clients; the JavaScript client takes SANSHAIN_BEST_EFFORT instead.
catalogInfo no — Path to the Backstage catalog-info.yaml the build pushes.
strict no false Strict mode: missing configuration and any Sanshain-Warning fail the build instead of warning.

Every relative path in the file (file, outputDirectory, catalogInfo) resolves against the directory that contains sanshain.yaml (named with --config on the Go, JavaScript and Conan CLIs, config_path in the Conan helper, configFile in Maven), not the working directory, so a build that points its client at another config file finds the same files.

The file carries no secrets. The token comes from SANSHAIN_TOKEN or the build tool's credential store, and is sent as Authorization: Bearer san_….

How stability is decided#

Sanshain never sees your git repository and the clients do no git detection. The rule is binary:

The provides section#

A list under provides, or a single provide object. Omit it entirely when the project only consumes.

Field Required Default Meaning
file yes — The specification file.
apiType no openapi openapi, asyncapi or proto.
retired no false The project no longer provides this family; see below.

The version is read from the file: MAJOR[.MINOR[.PATCH]], optionally v-prefixed; omitted parts are zero and the stored form is always three-part. For OpenAPI and AsyncAPI it is info.version. A proto file must carry exactly one // sanshain-version: MAJOR.MINOR.PATCH comment; a missing, malformed or conflicting marker rejects the provide with 400. The marker travels into the split files Consumers download.

Retiring a protocol#

Sanshain cannot tell a dropped protocol from a pipeline that stopped running, so removing a provides entry changes nothing on the server. To retire a family, keep the entry and set retired: true. The client sends a provide for that family with retired: true and no body. Sanshain clears the family's capability tag (messaging or grpc), closes its open trunk pins and releases its AsyncAPI message contracts, and answers what it shed:

{ "tag_cleared": "messaging", "trunk_pins_closed": 3, "contracts_released": 2 }

Version history stays and Consumers pinned to old versions keep resolving. A retire carries no body, no stability, no trunk and no tag; sending any of them alongside retired: true is refused with 400. Retiring needs the releaser role or a maintainer grant on the Producer; under dry_run the permission check runs and nothing is retired.

The requires section#

One entry per Producer and version. An entry with several endpoints becomes one POST /require-bundle; an entry with one endpoint becomes GET /require (or /require/asyncapi, /require/grpc).

Field Required Default Meaning
serviceName yes — The Producer to require from.
version yes — The exact pin, MAJOR.MINOR.PATCH. No ranges, no latest.
outputDirectory yes — Where the answered document is written.
endpoints yes — A list of method and path pairs; see the matching table.
apiType no openapi openapi, asyncapi or proto.
format no — Only openapi, only with apiType: asyncapi: receive the payload schemas as an OpenAPI document (below).

The pin is the whole contract: what the build downloads changes only when someone edits version. A pin that does not exist fails with 404 (unknown); a pin that exists but lacks an endpoint fails with 410 (absent), and a bundle names the missing endpoints.

Payload schemas as OpenAPI (format: openapi)#

A Consumer whose code generator only speaks OpenAPI can ask for the message payloads of an AsyncAPI requirement as an OpenAPI 3.0.3 document. The client sends format=openapi; the answer has info.title <producername> message schemas, info.version the pin, empty paths, and in components.schemas only the payload schemas of the required channels' messages with everything they reference. A payload given as $ref: '#/components/schemas/X' keeps the name X; an inline payload is named <MessageKey>Payload. Every $ref resolves inside the file, so openapi-generator with generateApis=false produces the DTOs. AsyncAPI 2.x and 3.x both work.

requires:
  - serviceName: order-service
    apiType: asyncapi
    format: openapi
    version: 2.0.0
    outputDirectory: generated/order-service-dtos
    endpoints:
      - method: PUB
        path: orders.created

To get both formats, list the entry twice with different outputDirectorys. The service answers 400 when two different schemas would get the same name, a payload uses a non-AsyncAPI schemaFormat (Avro, say), a $ref is external, or format is set on another apiType. The ETag of the OpenAPI answer never matches the AsyncAPI answer's.

How path and method match#

On a provide Sanshain splits the document and stores each endpoint under a path (or channel, or service) and a method (or operation, or rpc). A require looks those up at the pinned version. When the version exists but the values do not line up, the require fails 410.

Protocol Sanshain stores path method
OpenAPI each paths.* entry and HTTP verb the OpenAPI path, e.g. /api/v1/users/{id} the verb, case-insensitive (GET, POST, …)
AsyncAPI 2.x each channels.* key with publish / subscribe the channel key, e.g. orders.created PUB or SUB
AsyncAPI 3.x each operation, resolved to the channel address, send / receive the channel address, not the key PUB or SUB
Proto each service and rpc the fully qualified service, e.g. inventory.v1.InventoryService the rpc name, case-sensitive (GetProduct)

OpenAPI path matching is lenient: parameter names are normalised, so /users/{id} and /users/{userId} match; doubled separators and a trailing slash are ignored too. Two paths in one document that are the same endpoint after normalisation reject the provide with 400.

An OpenAPI example#

A user-service document with GET /api/v1/users, POST /api/v1/users and GET /api/v1/users/{id} is stored as three endpoints. To require two of them:

requires:
  - serviceName: user-service
    version: 1.0.0
    outputDirectory: generated/user-service
    endpoints:
      - method: GET
        path: /api/v1/users/{id}
      - method: POST
        path: /api/v1/users

The client calls POST /require-bundle and receives one merged OpenAPI document with the two operations and each shared schema once, which is why bundles exist: separate snippets would give typed languages two copies of CreateUserRequest that are not interchangeable.

AsyncAPI: the perspective convention#

Sanshain reads AsyncAPI 2.x publish and subscribe from the application's perspective: publish means this service publishes to the channel, subscribe means this service consumes it. The official 2.x specification defines the keywords from the client's perspective, exactly inverted. Sanshain uses the application reading because it matches the unambiguous 3.x send / receive mapping. A 2.x document authored with the spec-literal reading registers its message contracts and harvests its subscriptions exactly backwards.

So a Consumer requires a channel the Producer publishes with method: PUB. A Consumer's own subscribe operations need no require at all: they are harvested from its provide (see the API lifecycle).

Moving from 2.x to 3.x changes the identifier: 2.x stores the channel key, 3.x the channel address, the key being only a local label. Write the address in path; when a 3.x channel has no address, Sanshain falls back to the key.

# AsyncAPI 3.x:
#   channels:
#     OrderCreated:               # a local key
#       address: orders.created   # what Sanshain stores
requires:
  - serviceName: order-service
    apiType: asyncapi
    version: 2.0.0
    outputDirectory: generated/order-service
    endpoints:
      - method: PUB
        path: orders.created      # the address

Proto#

The answer to a proto require is a .proto file with the service, only the requested rpcs, and every message they reference, transitively, with the // sanshain-version: marker in it.

requires:
  - serviceName: inventory-service
    apiType: proto
    version: 1.0.0
    outputDirectory: generated/inventory-service
    endpoints:
      - method: GetProduct
        path: inventory.v1.InventoryService
      - method: ListProducts
        path: inventory.v1.InventoryService

What the clients send#

The provide phase#

For each entry: read the file, decide the stability from the ga switch, and call POST /provide, /provide/asyncapi or /provide/grpc with the document, the stability and, when the build declares a stream, trunk or tag. Every provide answers 202 Accepted:

{
  "version": "1.4.0",
  "stability": "ga",
  "content_hash": "sha256:…",
  "changes": { "inserts": 1, "updates": 2, "deletes": 0 },
  "promoted": false
}

changes is the endpoint diff against what that exact version stored before; a new version reports its whole endpoint set as inserts, and byte-identical content reports all zero, the idempotent no-op. An AsyncAPI provide adds harvested_subscriptions. A 409 carries proposed_version: set it in the spec file and republish, there is nothing to pull or merge. A 400 means the document itself is invalid, most often a missing or non-semver version.

The catalog phase#

A step of its own (sanshain catalog, sanshain:catalog, Sanshain(self).catalog()), because a pure Consumer never provides and a pure Producer may never require. Once per build the client reads the file at catalogInfo and calls PUT /catalog/<serviceName> with {"catalog_info_yaml": "…", "trunk": true|false}; trunk follows the same switch as provides and requires. Without catalogInfo the client warns and skips, or fails under strict. Sanshain reads only the Component whose metadata.name equals serviceName (case-insensitive), and of it:

Field What becomes of it
spec.dependsOn Only resource: refs become peripheral resources. A ref without a kind is read as a resource; one without a namespace takes the Component's namespace (default when it has none); refs are lower-cased. component: refs and consumesApis are ignored: edges between systems are what requires record.
spec.owner Shown as the owner with the ref's kind and name: a bare team-orders is group:default/team-orders, shown as team-orders (group).
metadata.description Shown under the title; trimmed, at most 1000 characters.
metadata.annotations["backstage.io/source-location"] The repository chip, without the url: prefix; kept only as an http(s) URL of at most 2048 characters, labelled by its host.
metadata.links Link chips by title (the URL's host when there is none); type and icon kept as text. A link that is not http(s) is dropped; at most 20 links.

Everything else is ignored. Each push replaces the previous state: a resource no longer listed leaves the current graph (it stays in the timeline), a file without owner or links leaves those empty, and a push whose content has not changed changes nothing. A trunk push updates the main graph and the dev graph; a plain push only the dev graph. The answer is {"system", "trunk", "resources": [...], "recorded": true}. When Sanshain cannot read the file as this system's Component (invalid YAML, no or more than one matching Component, usually a metadata.name that is not the serviceName), it still answers 200 with "recorded": false, records nothing, keeps the previous state and names the reason in a Sanshain-Warning header. Older services answer 404; the catalog push needs Sanshain Service 2.4.0 or later.

The require phase#

For each entry: one bundle or one single require, then the answer is written to outputDirectory and code generation runs on it. Every require answer carries an ETag; the clients store it and send If-None-Match next time, so an unchanged pin answers 304 and no file is rewritten. A GA pin never changes; a snapshot pin can, and the ETag is what detects that.

Checked against v2.6.0.