Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
*.go text eol=lf
specs/** -text
conformance/** text eol=lf
14 changes: 9 additions & 5 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
# Release and security-sensitive repository controls.
/.github/CODEOWNERS @fuller @ximt
/.github/workflows/** @fuller @ximt
/.github/CODEOWNERS @gemini/principal-engineers
/.github/workflows/** @gemini/principal-engineers
/.github/workflows/publish-typescript-sdk.yml @gemini/principal-engineers
/SECURITY.md @fuller @ximt
/SECURITY.md @gemini/principal-engineers

# Published TypeScript SDK source.
/packages/sdk-typescript/** @fuller @ximt
/packages/sdk-typescript/** @gemini/principal-engineers

# Published TypeScript SDK release metadata.
/packages/sdk-typescript/package.json @gemini/principal-engineers
/packages/sdk-typescript/package-lock.json @gemini/principal-engineers

# Published Go SDK source, module metadata, and release workflow.
/packages/sdk-go/** @fuller @ximt
/packages/sdk-go/** @gemini/principal-engineers

# Cross-SDK API contract and conformance fixtures.
/specs/** @gemini/principal-engineers
/conformance/** @gemini/principal-engineers
2 changes: 2 additions & 0 deletions .github/workflows/validate-go-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "packages/sdk-go/**"
- "specs/**"
- "conformance/**"
- ".github/workflows/validate-go-sdk.yml"
- ".github/workflows/release-go-sdk.yml"

Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/validate-typescript-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "packages/sdk-typescript/**"
- "specs/**"
- "conformance/**"
- ".github/workflows/validate-typescript-sdk.yml"

permissions:
Expand Down
189 changes: 189 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# API contract conformance fixtures

This directory is the language-neutral wire contract for the Gemini SDKs. The
fixtures are deterministic, contain no credentials that grant access, and do
not call the network. A runner loads `manifest.json`, executes every case in
its listed suite, and asserts the observable request, response, decoded value,
or WebSocket frame described by that case.

## Manifest and layout

`manifest.json` is the single case enumeration. Each suite has an `id`, a
`kind`, and a list of case file names. A case is stored at
`conformance/<suite id>/<case>.json`; for example,
`http/errors/not-found-404-empty-body.json`. The top-level `exceptions` array
is the authoritative list of reviewed cross-SDK tolerances. It must contain
the same IDs as `specs/overlays/gemini-wire-exceptions.yaml`.

Runners enforce the contract in both directions: every manifest entry must
have exactly one JSON file, and every fixture JSON file (other than the
manifest itself) must be listed. They also reject a manifest that silently
shrinks coverage. Case-level `exceptions` references must be declared in the
wire-exceptions overlay, and overlay IDs must be represented in the manifest
(the overlay may intentionally declare an ID with no cases).

All files are UTF-8 JSON with LF line endings. Values that are significant on
the wire are represented as strings or raw JSON numbers in the fixture; a
runner must preserve number text rather than passing unsafe integers through a
platform-sized floating-point value.

## Case schemas

The six `kind` values are described below. Fields not marked optional are
required. `id` is always the full path `<suite id>/<case id>` and `kind` must
match its manifest suite.

### `hmacRequest`

```json
{
"id": "http/hmac-requests/example",
"kind": "hmacRequest",
"credentials": { "apiKey": "...", "apiSecret": "..." },
"nonce": { "mode": "monotonic", "value": "1700000000000" },
"request": { "method": "POST", "path": "/v1/order/new", "body": {} },
"expect": {
"headers": ["X-GEMINI-APIKEY", "X-GEMINI-PAYLOAD", "X-GEMINI-SIGNATURE"],
"apiKeyHeader": "...",
"payload": { "request": "/v1/order/new", "nonce": "...", "fields": {} },
"signature": { "algorithm": "HMAC-SHA384", "over": "payloadBase64", "encoding": "hex-lower" }
},
"exceptions": ["rest-nonce-json-type"]
}
```

`nonce.mode` is `monotonic` (the exact decimal `value` is supplied) or
`websocket` (no exact value is supplied). For a monotonic request, the runner
sends the request through the SDK's HTTP signing seam. Every expected header
must be present and non-empty; the API-key header must equal `apiKeyHeader`.
Base64-decode `X-GEMINI-PAYLOAD`, parse it losslessly, and assert `request`,
nonce decimal text (allowing optional JSON string quotes), and every declared
field's raw JSON value. The payload may contain only `request`, `nonce`, and
the declared fields. Recompute HMAC-SHA384 over the base64 payload text and
compare the lowercase hexadecimal signature. For `websocket` nonce cases,
assert that `X-GEMINI-PAYLOAD` decodes to the `X-GEMINI-NONCE` value, that the
nonce is ten decimal epoch-second digits, and that the signature verifies.

### `unsignedRequest`

```json
{
"id": "http/unsigned-requests/example",
"kind": "unsignedRequest",
"operation": "marketData.getTicker",
"input": { "symbol": "BTCUSD" },
"expect": {
"method": "GET",
"path": "/v1/pubticker/BTCUSD",
"query": {},
"authHeaders": []
}
}
```

`operation` identifies the SDK service wrapper. The runner invokes it with
`input` through a capturing HTTP transport and asserts method, path, query
values, and absence of authentication headers. Array query values are
repeated plain keys (not comma-joined); query object ordering is not
significant.

### `errorMapping`

```json
{
"id": "http/errors/example",
"kind": "errorMapping",
"response": {
"status": 403,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"MissingRole\"}"
},
"expect": { "kind": "missing_role", "reason": "MissingRole" }
}
```

`response.body` is verbatim response text. Runners feed the status, headers,
and body through their SDK transport and map the canonical expected kinds:
`invalid_nonce`, `missing_nonce`, `invalid_signature`, `missing_role`,
`terms_required`, `insufficient_funds`, `rate_limited`, `order_not_found`,
`market_closed`, `not_found`, `service_error`, and `invalid_request`.
Observed status must match. If `reason` is present, it must be preserved on
the resulting error. The rate-limit case also has
`retryAfterSeconds` and requires the SDK's retry-after metadata to equal that
number (three seconds in the checked-in fixture).

### `jsonDecoding`

```json
{
"id": "json/example",
"kind": "jsonDecoding",
"raw": "{\"v\":9007199254740993}",
"field": "v",
"expect": { "valueKind": "integer", "text": "9007199254740993" }
}
```

`raw` is the exact JSON input and `field` identifies the decoded value.
`valueKind` is `integer` or `decimal`. The runner decodes with its lossless
JSON path, or with the language's exact integer/decimal type, and renders the
value as the expected decimal text. Unsafe integers must never be rounded;
decimal strings and decimal JSON numbers must retain their exact value.

### `wsSubscription`

```json
{
"id": "websocket/subscriptions/example",
"kind": "wsSubscription",
"stream": "trades",
"symbol": "BTCUSD",
"options": {},
"expect": { "method": "SUBSCRIBE", "params": ["btcusd@trade"] },
"exceptions": ["ws-subscription-id-scope"]
}
```

`stream` is one of `trades`, `bookTicker`, `depthUpdates`, `partialDepth`,
`contractStatus`, `orders`, `balances`, or `positions`. `symbol` is required
for symbol streams and omitted for global/private streams. `options` may
contain `intervalMs` (100 or 1000 as applicable), `levels` (5, 10, or 20), or
private `scope` (`account` or `session`). The runner maps the stream to the
SDK's typed call, captures the single wire frame, and asserts `method`, exact
`params`, and a positive integer `id`. It must acknowledge the request locally;
no network or wall-clock value is needed for this assertion.

### `wsEvent`

```json
{
"id": "websocket/events/example",
"kind": "wsEvent",
"stream": "trades",
"symbol": "BTCUSD",
"frame": "{\"e\":\"trade\",\"s\":\"btcusd\"}",
"expect": {
"fields": {
"s": { "text": "BTCUSD", "compare": "caseInsensitive" }
}
},
"exceptions": ["ws-inbound-symbol-case"]
}
```

`frame` is verbatim inbound WebSocket JSON. `stream` selects the typed SDK
subscription and `expect.fields` names decoded wire fields. Each field has
expected rendered `text` and `compare` of `exact` or `caseInsensitive`.
Runners assert that events are delivered to the selected subscription; the
case-insensitive comparison is used only for the reviewed symbol-case
exception. Wide update IDs are compared by decimal text.

## Implementing another runner

A new SDK runner should iterate suites from `manifest.json` rather than
hard-code case names, load each case from its suite directory, and implement
all six schemas above. It should use an in-memory HTTP/WebSocket double,
perform independent signature verification, preserve raw numeric text, and
fail on missing, extra, or unlisted fixtures. It must also compare the
manifest exception IDs and every case exception reference with the reviewed
wire-exceptions overlay before executing behavioral cases.
13 changes: 13 additions & 0 deletions conformance/http/errors/insufficient-funds-406.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/insufficient-funds-406",
"kind": "errorMapping",
"response": {
"status": 406,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"InsufficientFunds\",\"message\":\"Insufficient funds\"}"
},
"expect": {
"kind": "insufficient_funds",
"reason": "InsufficientFunds"
}
}
13 changes: 13 additions & 0 deletions conformance/http/errors/invalid-nonce-400.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/invalid-nonce-400",
"kind": "errorMapping",
"response": {
"status": 400,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"InvalidNonce\",\"message\":\"Nonce must be greater than the previous nonce\"}"
},
"expect": {
"kind": "invalid_nonce",
"reason": "InvalidNonce"
}
}
13 changes: 13 additions & 0 deletions conformance/http/errors/market-closed-400.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/market-closed-400",
"kind": "errorMapping",
"response": {
"status": 400,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"MarketClosed\",\"message\":\"Market is closed\"}"
},
"expect": {
"kind": "market_closed",
"reason": "MarketClosed"
}
}
13 changes: 13 additions & 0 deletions conformance/http/errors/missing-role-403-error-envelope.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/missing-role-403-error-envelope",
"kind": "errorMapping",
"response": {
"status": 403,
"headers": { "content-type": "application/json" },
"body": "{\"error\":\"MissingRole\",\"message\":\"OrderStatus required\"}"
},
"expect": {
"kind": "missing_role",
"reason": "MissingRole"
}
}
13 changes: 13 additions & 0 deletions conformance/http/errors/missing-role-403-result-envelope.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/missing-role-403-result-envelope",
"kind": "errorMapping",
"response": {
"status": 403,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"MissingRole\",\"message\":\"OrderStatus required\"}"
},
"expect": {
"kind": "missing_role",
"reason": "MissingRole"
}
}
12 changes: 12 additions & 0 deletions conformance/http/errors/not-found-404-empty-body.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "http/errors/not-found-404-empty-body",
"kind": "errorMapping",
"response": {
"status": 404,
"headers": {},
"body": ""
},
"expect": {
"kind": "not_found"
}
}
13 changes: 13 additions & 0 deletions conformance/http/errors/order-not-found-404.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/order-not-found-404",
"kind": "errorMapping",
"response": {
"status": 404,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"OrderNotFound\",\"message\":\"Order not found\"}"
},
"expect": {
"kind": "order_not_found",
"reason": "OrderNotFound"
}
}
17 changes: 17 additions & 0 deletions conformance/http/errors/rate-limited-429-retry-after.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "http/errors/rate-limited-429-retry-after",
"kind": "errorMapping",
"response": {
"status": 429,
"headers": {
"content-type": "application/json",
"retry-after": "3"
},
"body": "{\"result\":\"error\",\"reason\":\"RateLimit\",\"message\":\"Rate limit exceeded\"}"
},
"expect": {
"kind": "rate_limited",
"reason": "RateLimit",
"retryAfterSeconds": 3
}
}
12 changes: 12 additions & 0 deletions conformance/http/errors/server-error-500-unstructured.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "http/errors/server-error-500-unstructured",
"kind": "errorMapping",
"response": {
"status": 500,
"headers": { "content-type": "text/plain" },
"body": "upstream unavailable"
},
"expect": {
"kind": "service_error"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/terms-required-400-accept-terms-required",
"kind": "errorMapping",
"response": {
"status": 400,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"AcceptTermsRequired\",\"message\":\"Terms must be accepted before placing orders\"}"
},
"expect": {
"kind": "terms_required",
"reason": "AcceptTermsRequired"
}
}
13 changes: 13 additions & 0 deletions conformance/http/errors/terms-required-400-must-accept.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"id": "http/errors/terms-required-400-must-accept",
"kind": "errorMapping",
"response": {
"status": 400,
"headers": { "content-type": "application/json" },
"body": "{\"result\":\"error\",\"reason\":\"MustAccepTerms\",\"message\":\"Terms must be accepted before placing orders\"}"
},
"expect": {
"kind": "terms_required",
"reason": "MustAccepTerms"
}
}
Loading
Loading