Skip to content
Merged
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
70 changes: 56 additions & 14 deletions scripts/lib/openapi-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {
// Examples
// -------------------------------------------------------------------------

// The JSON body of a request or response. Error responses are served as
// `application/problem+json` (RFC 9457) rather than `application/json`, so
// looking only at the latter silently drops the Problem schema from every
// error row. Fall back to any JSON-suffixed media type.
function jsonBody(carrier) {
const content = carrier?.content;
if (!content) return undefined;
return (
content["application/json"] ??
content["application/problem+json"] ??
Object.entries(content).find(([type]) => /\bjson\b|\+json$/.test(type))?.[1]
);
}

// OpenAPI 3.1 permits a media type to carry named `examples`. Prefer the one
// named `default`, else the first — a hand-written example beats anything
// synthesized from the schema.
Expand All @@ -102,7 +116,7 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {
}

function requestExample(operation) {
const media = operation.requestBody?.content?.["application/json"];
const media = jsonBody(operation.requestBody);
const authored = mediaTypeExample(media);
if (authored !== undefined) return authored;

Expand Down Expand Up @@ -155,8 +169,8 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {
return `?${pairs.join("&")}`;
}

function curlExample(method, path, operation) {
const url = `${baseUrl}${path}${requiredQueryString(operation.parameters)}`;
function curlExample(method, path, operation, params) {
const url = `${baseUrl}${path}${requiredQueryString(params ?? operation.parameters)}`;
const lines = [
`curl -X ${METHOD_LABELS[method] ?? method.toUpperCase()} '${url}'`,
` -H 'Authorization: Bearer ${tokenPlaceholder}'`,
Expand All @@ -178,7 +192,7 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {

const rawResp = rawResponses[successCode];
const resp = rawResp.$ref ? resolveRef(rawResp.$ref) : rawResp;
const media = resp.content?.["application/json"];
const media = jsonBody(resp);

// An example authored on the response wins outright — it shows the whole
// envelope, including any `meta`, exactly as the API returns it.
Expand Down Expand Up @@ -247,7 +261,7 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {

function requestBodySection(requestBody) {
if (!requestBody) return "";
const schema = requestBody.content?.["application/json"]?.schema;
const schema = jsonBody(requestBody)?.schema;
if (!schema) return "";
const resolved = deref(schema);
const required = resolved.required ?? [];
Expand Down Expand Up @@ -286,7 +300,7 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {
// Resolve top-level $ref (e.g. $ref: "#/components/responses/Unauthorized")
const resp = rawResp.$ref ? resolveRef(rawResp.$ref) : rawResp;
const desc = escapeMarkdown(resp.description ?? "");
const schema = resp.content?.["application/json"]?.schema;
const schema = jsonBody(resp)?.schema;
const name = responseSchemaName(schema);
const schemaLink = name
? `[\`${name}\`](${modelsHref}#model-${name.replace("[]", "").toLowerCase()})`
Expand All @@ -297,7 +311,26 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {
return `\n**Responses**\n\n| Status | Description | Schema |\n|--------|-------------|--------|\n${rows}\n`;
}

function endpointBlock(method, path, operation, headingLevel = "###") {
// Parameters declared on the path item apply to every operation under it
// (OpenAPI 3.1 §4.8.9) — a spec typically hoists them there once a path has
// more than one method. Operation-level entries override an inherited one
// with the same name and location.
function effectiveParameters(pathParams, opParams) {
const merged = [];
const seen = new Map(); // "in:name" -> index in merged
for (const raw of [...(pathParams ?? []), ...(opParams ?? [])]) {
const p = deref(raw);
const key = `${p.in}:${p.name}`;
if (seen.has(key)) merged[seen.get(key)] = p;
else {
seen.set(key, merged.length);
merged.push(p);
}
}
return merged;
}

function endpointBlock(method, path, operation, headingLevel = "###", pathParams) {
const anchor = `{#${operation.operationId}}`;
const title = operation.summary
? operation.summary.replace(/\.$/, "")
Expand All @@ -309,11 +342,12 @@ function createRenderer(spec, { baseUrl, tokenPlaceholder, modelsHref }) {
operation.description.trim() !== (operation.summary ?? "").trim()
? `${operation.description.trim()}\n\n`
: "";
const params = parametersSection(operation.parameters);
const allParams = effectiveParameters(pathParams, operation.parameters);
const params = parametersSection(allParams);
const body = requestBodySection(deref(operation.requestBody ?? {}));
const responses = responsesSection(operation.responses);
const successExample = successExampleBlock(operation.responses);
const curl = `\n**Example request**\n\n\`\`\`sh\n${curlExample(method, path, operation)}\n\`\`\`\n`;
const curl = `\n**Example request**\n\n\`\`\`sh\n${curlExample(method, path, operation, allParams)}\n\`\`\`\n`;
return [
heading,
methodPath,
Expand Down Expand Up @@ -412,14 +446,20 @@ export function generateApiDocs(config) {
modelsHref: models.href,
});

// Collect operations by tag.
const byTag = {}; // tag → [{ method, path, operation }]
// Collect operations by tag. pathParams carries the path item's own
// parameters, which every operation under it inherits.
const byTag = {}; // tag → [{ method, path, operation, pathParams }]
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
for (const method of HTTP_METHODS) {
const op = pathItem[method];
if (!op) continue;
for (const tag of op.tags ?? ["Untagged"]) {
(byTag[tag] ??= []).push({ method, path, operation: op });
(byTag[tag] ??= []).push({
method,
path,
operation: op,
pathParams: pathItem.parameters,
});
}
}
}
Expand All @@ -443,7 +483,7 @@ export function generateApiDocs(config) {
.map((g) => {
const endpoints = grouped[g]
.map((item) =>
endpointBlock(item.method, item.path, item.operation, "###")
endpointBlock(item.method, item.path, item.operation, "###", item.pathParams)
)
.join("\n---\n\n");
return `## ${g}\n\n${endpoints}`;
Expand All @@ -453,7 +493,9 @@ export function generateApiDocs(config) {
}

const content = items
.map((item) => endpointBlock(item.method, item.path, item.operation, "##"))
.map((item) =>
endpointBlock(item.method, item.path, item.operation, "##", item.pathParams)
)
.join("\n---\n\n");
return `${frontmatter}\n\n${intro}${content}\n`;
}
Expand Down
2 changes: 1 addition & 1 deletion site/dolt/src/content/products/dolthub/api/v2/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ A structured error body returned for every non-2xx response, following RFC 9457

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `string` | no | A URI identifying the problem type; when dereferenced it points at human-readable documentation for the error. |
| `type` | `string` | yes | A URI identifying the problem type; when dereferenced it points at human-readable documentation for the error. |
| `title` | `string` | yes | A short, human-readable summary of the problem type. |
| `status` | `integer` | yes | The HTTP status code, repeated in the body for convenience. |
| `detail` | `string` | no | A human-readable explanation specific to this occurrence of the problem. |
Expand Down
11 changes: 10 additions & 1 deletion site/dolt/src/content/products/hosted/api/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ See [Authentication](/products/hosted/api/v1/authentication) for how to create a
| **GET** | `/api/v1/deployments/{owner}` | [List an owner's deployments](/products/hosted/api/v1/deployment#listDeployments) |
| **GET** | `/api/v1/deployments/{owner}/{deployment}` | [Get a deployment](/products/hosted/api/v1/deployment#getDeployment) |
| **GET** | `/api/v1/deployments/{owner}/{deployment}/instances` | [List a deployment's instances](/products/hosted/api/v1/deployment#listDeploymentInstances) |
| **POST** | `/api/v1/deployments/{owner}/{deployment}/instances` | [Add a read replica to a deployment](/products/hosted/api/v1/deployment#addDeploymentInstance) |
| **DELETE** | `/api/v1/deployments/{owner}/{deployment}/instances/{id}` | [Remove an instance from a deployment](/products/hosted/api/v1/deployment#deleteDeploymentInstance) |
| **GET** | `/api/v1/deployments/{owner}/{deployment}/config` | [Get a deployment's configuration](/products/hosted/api/v1/deployment#getDeploymentConfig) |
| **GET** | `/api/v1/deployments/{owner}/{deployment}/backups` | [List a deployment's backups](/products/hosted/api/v1/deployment#listDeploymentBackups) |
| **POST** | `/api/v1/deployments/{owner}/{deployment}/disable` | [Disable a deployment](/products/hosted/api/v1/deployment#disableDeployment) |

## Response shape

Expand Down Expand Up @@ -92,9 +97,13 @@ Every response, including successful ones, carries an `x-request-id` header, ech

Creating a deployment returns `202 Accepted` with the deployment in its `starting` state — provisioning continues after the response. Poll [Get a deployment](/products/hosted/api/v1/deployment#getDeployment) until `state` becomes `started`.

[Disabling a deployment](/products/hosted/api/v1/deployment#disableDeployment) works the same way: `202 Accepted` with the deployment in `stopping`, then poll until `state` is `stopped`.

Instance changes are also `202`, but there is no per-instance `state` field to poll, so they are observed through the [instance list](/products/hosted/api/v1/deployment#listDeploymentInstances) instead. After [adding a replica](/products/hosted/api/v1/deployment#addDeploymentInstance), poll until that instance reports a `host` — that is when it is reachable. After [removing one](/products/hosted/api/v1/deployment#deleteDeploymentInstance), poll until it disappears from the list, which only reports instances that aren't stopped.

Deployment names are unique within an owner, which makes creates idempotent by name: retrying after an ambiguous failure returns `409 Conflict` rather than provisioning a second deployment.

> **Creating a deployment incurs cost.**
> **Creating a deployment incurs cost.** Disabling one tears down its instances and their storage — [take a backup first](/products/hosted/api/v1/deployment#listDeploymentBackups) if you want the data.

## Stability

Expand Down
Loading
Loading