diff --git a/Dockerfile b/Dockerfile index b0ab6a5993cf..c0d288a83d11 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ ARG PAGEFIND_VERSION=1.5.2 # base defines the generic base stage FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS base RUN apk add --no-cache \ + bash \ git \ nodejs \ npm \ @@ -41,6 +42,9 @@ WORKDIR /project COPY --from=hugo /out/hugo /bin/hugo COPY --from=npm /out/node_modules node_modules COPY . . +RUN --mount=type=cache,target=/root/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + ./hack/api-docs/run.sh test && ./hack/api-docs/run.sh generate # build creates production builds with Hugo FROM build-base AS build @@ -58,7 +62,9 @@ RUN --mount=type=cache,target=/tmp/hugo_cache \ --printUnusedTemplates \ -b $DOCS_URL \ -e $HUGO_ENV -RUN ./hack/flatten-and-resolve.js public +RUN node --test hack/test/flatten-and-resolve.mjs +RUN node hack/flatten-and-resolve.js public +RUN node hack/api-docs/verify-output.mjs public # lint lints markdown files FROM ghcr.io/rvben/rumdl:0.2.49-alpine AS lint diff --git a/_vale/config/vocabularies/Docker/accept.txt b/_vale/config/vocabularies/Docker/accept.txt index 75ac4e3e93b7..910369f45322 100644 --- a/_vale/config/vocabularies/Docker/accept.txt +++ b/_vale/config/vocabularies/Docker/accept.txt @@ -449,3 +449,4 @@ superset tokenization WebGL Xubuntu +Netlify diff --git a/assets/api-reference/reference.css b/assets/api-reference/reference.css new file mode 100644 index 000000000000..862feee37c89 --- /dev/null +++ b/assets/api-reference/reference.css @@ -0,0 +1,596 @@ +/* Scoped reference tokens inherit Docker's palette and installed typefaces. */ +.api-reference, +.api-nav { + --api-text: var(--color-gray-800, #2c333f); + --api-muted: var(--color-gray-600, #566581); + --api-line: var(--color-gray-100, #e7eaef); + --api-surface: var(--color-background-light, #f9f9fa); + --api-paper: #fff; + --api-link: var(--color-blue-600, #0d4df2); + --api-selected: var(--color-blue-50, #f6f8fe); + --api-mono: "Roboto Mono", ui-monospace, monospace; + color: var(--api-text); +} +.dark .api-reference, +.dark .api-nav { + --api-text: var(--color-gray-100, #e7eaef); + --api-muted: var(--color-gray-300, #a9b4c6); + --api-line: var(--color-gray-800, #2c333f); + --api-surface: var(--color-gray-900, #1e2129); + --api-paper: var(--color-background-dark, #10151b); + --api-link: var(--color-blue-300, #7ba4f4); + --api-selected: #17243b; + color-scheme: dark; +} +.api-reference { + max-width: 1440px; + margin: auto; + font-size: 15px; + line-height: 1.7; + overflow-wrap: anywhere; +} +.api-reference h1 { + font-size: 32px; + font-weight: 550; + letter-spacing: -0.025em; + line-height: 1.25; + margin: 28px 0 20px; + text-wrap: balance; +} +.api-reference h2 { + font-size: 20px; + font-weight: 550; + letter-spacing: -0.015em; + line-height: 1.4; + margin: 36px 0 14px; +} +.api-reference h3, +.api-reference h4 { + font-size: 15px; + font-weight: 550; + line-height: 1.5; + margin: 20px 0 8px; +} +.api-reference p { + margin: 10px 0; + max-width: 72ch; +} +.api-reference .prose { + color: var(--api-text); + font-size: inherit; + line-height: inherit; + max-width: 72ch; +} +.api-reference .prose > :first-child { + margin-top: 0; +} +.api-reference .prose > :last-child { + margin-bottom: 0; +} +.api-reference .prose li > p { + margin: 6px 0; +} +.api-reference .prose :is(h1, h2, h3, h4, h5, h6) > a { + color: inherit; + text-decoration: none; +} +.api-reference .prose :is(h1, h2, h3, h4, h5, h6) > a:hover { + text-decoration: underline; +} +.api-overview { + max-width: 72ch; + margin: 32px 0; +} +.api-reference .api-overview .prose :is(h3, h4) { + font-size: 17px; + font-weight: 550; + line-height: 1.4; + margin: 28px 0 12px; +} +.api-reference a, +.api-nav a { + color: var(--api-link); + text-underline-offset: 3px; +} +.api-reference a:hover { + text-decoration: underline; +} +.api-reference :is(a, button, input, select, summary):focus-visible, +.api-nav a:focus-visible { + outline: 2px solid var(--api-link); + outline-offset: 4px; + border-radius: 3px; +} +.api-reference code { + font-family: var(--api-mono); + font-size: 13px; + font-weight: 400; + overflow-wrap: anywhere; +} +.api-reference .prose code::before, +.api-reference .prose code::after { + content: none; +} +.api-reference pre { + background: var(--api-surface); + color: var(--api-text); + border: 1px solid var(--api-line); + padding: 18px 20px; + border-radius: 6px; + overflow: auto; + font-family: var(--api-mono); + font-size: 13px; + line-height: 1.75; + max-height: 34rem; + margin: 14px 0; + tab-size: 2; +} +.api-reference pre code { + font: inherit; + padding: 0; + background: none; + color: inherit; +} +.api-reference summary { + cursor: pointer; + padding: 6px 0; + font-size: 13px; + color: var(--api-muted); +} +.api-reference summary::marker { + color: var(--api-muted); + font-size: 10px; +} +.api-reference summary:hover { + color: var(--api-link); +} +.api-eyebrow, +.api-label { + font-size: 13px; + font-weight: 500; + color: var(--api-muted); +} +.api-reference .api-eyebrow { + margin-top: 28px; + margin-bottom: -16px; +} +.api-lead { + font-size: 17px; + color: var(--api-muted); +} +.api-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin: 32px 0; +} +.api-card { + display: block; + border: 1px solid var(--api-line); + border-radius: 10px; + padding: 26px; +} +.api-card:hover { + background: var(--api-selected); + border-color: var(--api-link); + text-decoration: none !important; +} +.api-card h2 { + margin: 12px 0; + color: var(--api-text); +} +.api-card p { + color: var(--api-muted); +} +.api-card > span:last-child { + display: inline-block; + margin-top: 10px; + font-size: 14px; +} +.api-crumbs { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + color: var(--api-muted); + font-size: 13px; + margin-bottom: 12px; +} +.api-crumbs a { + color: var(--api-muted); +} +.api-tools { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + align-items: center; + font-size: 13px; + padding-bottom: 20px; + border-bottom: 1px solid var(--api-line); +} +.api-tools > label { + margin-right: auto; +} +.api-reference label { + font-size: 13px; + color: var(--api-muted); +} +.api-reference select, +.api-reference input { + border: 1px solid var(--api-line); + border-radius: 6px; + background: var(--api-paper); + padding: 8px 10px; + color: var(--api-text); + font: inherit; + font-size: 14px; + max-width: 100%; +} +.api-reference select { + margin-left: 8px; +} +.api-reference input::placeholder { + color: var(--api-muted); +} +.api-connection { + max-width: 72ch; + margin: 32px 0; +} +.api-filter-label { + display: block; + margin: 16px 0 24px; +} +.api-filter-label input { + display: block; + width: 100%; + max-width: 36rem; + margin: 8px 0; +} +.api-operation-row { + display: grid; + grid-template-columns: 64px minmax(0, 1.2fr) minmax(0, 1fr); + align-items: start; + gap: 16px; + padding: 16px 8px; + border-bottom: 1px solid var(--api-line); + font-size: 14px; +} +.api-operation-row > span:last-child { + color: var(--api-muted); +} +.api-operation-row:hover { + background: var(--api-selected); + text-decoration: none !important; +} +.api-method { + display: inline-block; + font-size: 11px; + font-weight: 650; + background: var(--api-selected); + color: var(--api-link); + border-radius: 4px; + padding: 4px 8px; + line-height: 1.5; + white-space: nowrap; + width: fit-content; + flex-shrink: 0; +} +.api-method[data-method="POST"] { + background: #ebf9ee; + color: #216e4b; +} +.api-method[data-method="DELETE"] { + background: #fff0ef; + color: #ad3039; +} +.api-method[data-method="PATCH"], +.api-method[data-method="PUT"] { + background: #fff5e0; + color: #865514; +} +.dark .api-method[data-method="POST"] { + background: #173728; + color: #a9e5bd; +} +.dark .api-method[data-method="DELETE"] { + background: #3d2229; + color: #f5b6b6; +} +.dark .api-method[data-method="PATCH"], +.dark .api-method[data-method="PUT"] { + background: #392e1c; + color: #edcd96; +} +.api-signature { + display: flex; + align-items: baseline; + gap: 14px; + background: var(--api-surface); + border: 1px solid var(--api-line); + border-radius: 6px; + padding: 14px 18px; + margin: 0 0 28px; +} +.api-signature code { + font-size: 14px; +} +.api-operation-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); + gap: 40px; + align-items: start; +} +.api-reading { + min-width: 0; +} +.api-description { + margin-bottom: 32px; +} +.api-description:empty { + display: none; +} +.api-request { + position: sticky; + top: 88px; + max-height: calc(100dvh - 112px); + overflow: auto; + min-width: 0; + border: 1px solid var(--api-line); + border-radius: 8px; + padding: 20px; + background: var(--api-surface); + font-size: 13px; + line-height: 1.65; +} +.api-request-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.api-request-heading h2 { + margin: 0; + font-size: 15px; + letter-spacing: 0; +} +.api-request-heading > span { + color: var(--api-muted); + font-size: 12px; +} +.api-request > p { + color: var(--api-muted); + margin: 12px 0; +} +.api-request pre { + background: var(--api-paper); + padding: 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.api-request pre code { + word-break: break-all; +} +.api-request button { + border: 1px solid var(--api-line); + background: var(--api-paper); + color: var(--api-text); + padding: 7px 12px; + border-radius: 5px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.api-request button:hover { + border-color: var(--api-link); + color: var(--api-link); +} +.api-source-details { + border-top: 1px solid var(--api-line); + margin-top: 16px; + padding-top: 8px; +} +.api-field { + border-top: 1px solid var(--api-line); + padding: 16px 0; + margin: 0; +} +.api-field h3 { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; + margin: 0 0 10px; +} +.api-field h3 code, +.api-field > summary code { + color: var(--api-text); + font-weight: 500; +} +.api-field small { + font-size: 12px; + font-weight: 400; + color: var(--api-muted); +} +.api-field .api-required { + color: var(--api-text); + font-size: 12px; +} +.api-field .api-field { + border-top: 0; + border-left: 1px solid var(--api-line); + margin: 12px 0 0 4px; + padding: 0 0 0 16px; +} +.api-type, +.api-ref, +.api-constraints { + font-size: 13px; + color: var(--api-muted); +} +.api-variant { + border-top: 1px solid var(--api-line); + margin-top: 24px; + padding-top: 20px; +} +.api-variant > h3 { + display: flex; + gap: 10px; + align-items: baseline; + margin-top: 0; +} +.api-status { + border: 1px solid var(--api-line); + padding: 1px 6px; + border-radius: 4px; +} +.api-media { + color: var(--api-muted); +} +.api-schema-links { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: 0 28px; + margin: 0; + padding: 0; + list-style: none; +} +.api-schema-links li { + min-width: 0; + border-bottom: 1px solid var(--api-line); +} +.api-schema-links a { + display: flex; + align-items: center; + height: 100%; + min-height: 44px; + padding: 10px 8px; +} +.api-schema-links a:hover { + background: var(--api-selected); +} +.api-nav { + padding: 20px 8px; + font-size: 14px; + line-height: 1.5; +} +.api-nav a { + display: block; + padding: 7px 10px; + margin: 2px 0; + color: var(--api-muted); +} +.api-nav a:hover { + color: var(--api-link); + background: var(--api-selected); +} +.api-nav a[aria-current="page"] { + background: var(--api-selected); + color: var(--api-link); + font-weight: 500; + box-shadow: inset 2px 0 var(--api-link); +} +.api-nav h2 { + font-size: 16px; + font-weight: 550; + margin: 24px 10px 4px; +} +.api-nav .api-nav-version { + color: var(--api-muted); + font-size: 13px; + margin: 0 10px 16px; +} +.api-nav h3 { + font-size: 13px; + font-weight: 550; + margin: 26px 10px 8px; +} +.api-nav .api-nav-operation { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 8px; + align-items: baseline; +} +.api-nav small { + font-size: 11px; + font-weight: 600; + color: var(--api-link); +} +.api-nav small[data-method="POST"] { + color: #216e4b; +} +.api-nav small[data-method="DELETE"] { + color: #ad3039; +} +.api-nav small[data-method="PATCH"], +.api-nav small[data-method="PUT"] { + color: #865514; +} +.dark .api-nav small[data-method="POST"] { + color: #a9e5bd; +} +.dark .api-nav small[data-method="DELETE"] { + color: #f5b6b6; +} +.dark .api-nav small[data-method="PATCH"], +.dark .api-nav small[data-method="PUT"] { + color: #edcd96; +} +.api-nav .api-nav-back { + font-size: 13px; + color: var(--api-link); +} +.api-reference [hidden] { + display: none !important; +} +.api-reference[data-api-view="schema"] > .prose { + margin-bottom: 24px; +} +.api-reference[data-api-view="schema"] > .api-field { + max-width: 80ch; +} +@media (max-width: 1199px) { + .api-operation-grid { + grid-template-columns: 1fr; + gap: 28px; + } + .api-request { + position: static; + max-height: none; + overflow: visible; + grid-row: 1; + } +} +@media (max-width: 767px) { + main > div:has(> .api-reference) { + padding: 24px 20px; + } + .api-cards { + grid-template-columns: 1fr; + } + .api-reference h1 { + font-size: 28px; + } + .api-operation-row { + grid-template-columns: 56px minmax(0, 1fr); + gap: 6px 12px; + } + .api-operation-row > span:last-child { + grid-column: 2; + } + .api-tools { + gap: 14px 20px; + } + .api-tools > label { + flex-basis: 100%; + } + .api-signature { + padding: 12px; + gap: 10px; + } + .api-signature code { + font-size: 13px; + } + .api-request { + padding: 16px; + } +} diff --git a/assets/api-reference/reference.js b/assets/api-reference/reference.js new file mode 100644 index 000000000000..7c51e1843235 --- /dev/null +++ b/assets/api-reference/reference.js @@ -0,0 +1,49 @@ +document.querySelectorAll("[data-api-version]").forEach((select) => + select.addEventListener("change", () => { + location.href = select.value; + }), +); +document.querySelectorAll("[data-api-filter]").forEach((input) => + input.addEventListener("input", () => { + const term = input.value.toLocaleLowerCase(); + document.querySelectorAll("[data-api-filter-item]").forEach((row) => { + row.hidden = !row.textContent.toLocaleLowerCase().includes(term); + }); + }), +); +document.querySelectorAll("[data-api-copy]").forEach((button) => + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText( + button.parentElement.querySelector("[data-api-copy-source]") + .textContent, + ); + button.textContent = "Copied"; + } catch { + button.textContent = "Select and copy the request"; + } + }), +); +document.querySelectorAll("[data-api-example-select]").forEach((select) => { + const update = () => + select + .closest("[data-api-examples]") + .querySelectorAll("[data-api-example]") + .forEach((example) => { + example.hidden = example.dataset.apiExample !== select.value; + }); + select.addEventListener("change", update); + update(); +}); + +document.querySelectorAll("[data-api-media-select]").forEach((select) => + select.addEventListener("change", () => { + document.querySelectorAll("[data-api-media]").forEach((variant) => { + variant.hidden = Boolean( + select.value && + variant.dataset.apiMedia && + select.value !== variant.dataset.apiMedia, + ); + }); + }), +); diff --git a/content/reference/api/_content.gotmpl b/content/reference/api/_content.gotmpl new file mode 100644 index 000000000000..d229e946d87a --- /dev/null +++ b/content/reference/api/_content.gotmpl @@ -0,0 +1,22 @@ +{{- $data := index hugo.Data "api-reference" -}} +{{- if not $data }} + {{ errorf "API reference data missing: run ./hack/api-docs/run.sh generate before Hugo" }} +{{ end -}} +{{- if ne (int $data.modelVersion) 1 }} + {{ errorf "Unsupported API presentation model version" }} +{{ end -}} +{{- .AddPage (dict "path" "." "kind" "section" "title" "Docker APIs" "linkTitle" "API reference" "description" "Explore Docker HTTP APIs, connection requirements, and versioned references." "layout" "api-docs" "params" (dict "view" "catalog" "keywords" (slice "API" "HTTP" "OpenAPI"))) -}} +{{- range $api := $data.apis -}} + {{- $path := strings.TrimPrefix "/reference/api/" $api.url | strings.TrimSuffix "/" -}} + {{- $aliases := slice -}} + {{- if eq $api.id "dvp" }} + {{ $aliases = slice "/reference/api/hub/dvp/" }} + {{ end -}} + {{- $.AddPage (dict "linkTitle" "Latest" "path" $path "url" $api.url "aliases" $aliases "kind" "section" "title" (printf "%s API %v" $api.title $api.version) "description" (printf "%s HTTP API reference, version %v." $api.title $api.version) "layout" "api-docs" "params" (dict "view" "overview" "apiID" $api.id "keywords" (slice "API" $api.product))) -}} + {{- range $op := $api.operations -}} + {{- $.AddPage (dict "path" (printf "%s/operations/%s" $path (crypto.SHA256 $op.id)) "url" $op.url "title" $op.summary "description" (printf "%s %s — %s API %v." $op.method $op.path $api.title $api.version) "layout" "api-docs" "params" (dict "view" "operation" "apiID" $api.id "operationID" $op.id "keywords" (slice $op.method $op.path $op.id))) -}} + {{- end -}} + {{- range $schema := $api.schemas -}} + {{- $.AddPage (dict "path" (printf "%s/schemas/%s" $path (crypto.SHA256 $schema.name)) "url" $schema.url "title" $schema.name "description" (printf "%s schema in %s API %v." $schema.name $api.title $api.version) "layout" "api-docs" "params" (dict "view" "schema" "apiID" $api.id "schemaName" $schema.name "keywords" (slice "schema" $schema.name))) -}} + {{- end -}} +{{- end -}} diff --git a/content/reference/api/_index.md b/content/reference/api/_index.md deleted file mode 100644 index 53ae13d37bd4..000000000000 --- a/content/reference/api/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: API reference -build: - render: never ---- - diff --git a/content/reference/api/dvp/latest.md b/content/reference/api/dvp/latest.md deleted file mode 100644 index 1ef4fdf075ba..000000000000 --- a/content/reference/api/dvp/latest.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -layout: api -description: Reference documentation and Swagger (OpenAPI) specification for the Docker Verified Publisher API. -title: Docker Verified Publisher API reference -linkTitle: Latest -weight: 1 -aliases: - - /reference/api/hub/dvp/ ---- diff --git a/content/reference/api/dvp/latest.yaml b/content/reference/api/dvp/latest.yaml index 01811d55bc7c..292e2fe16c59 100644 --- a/content/reference/api/dvp/latest.yaml +++ b/content/reference/api/dvp/latest.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.0 +openapi: 3.2.0 info: title: DVP Data API version: 1.0.0 @@ -6,75 +6,79 @@ info: url: https://docs.docker.com/assets/images/logo-docker-main.png href: /reference description: | - The Docker DVP Data API allows [Docker Verified Publishers](https://docs.docker.com/docker-hub/publish/) to view image pull analytics data for their namespaces. Analytics data can be retrieved in a CSV as raw data, or in a summary format. - - #### Summary data + The Docker DVP Data API allows [Docker Verified Publishers](https://docs.docker.com/docker-hub/publish/) to view image pull analytics data for their namespaces. Analytics data can be retrieved in a CSV as raw data, or in a summary format. + + #### Summary data - In your summary data CSV, you will have access to the data points listed below. You can request summary data for a complete week (Monday through Sunday) or for a complete month (available on the first day of the following month). + In your summary data CSV, you will have access to the data points listed below. You can request summary data for a complete week (Monday through Sunday) or for a complete month (available on the first day of the following month). - There are two levels of summary data: + There are two levels of summary data: - - Repository-level, a summary of every namespace and repository - - Tag- or digest-level, a summary of every namespace, repository, and reference - (tag or digest) + - Repository-level, a summary of every namespace and repository + - Tag- or digest-level, a summary of every namespace, repository, and reference + (tag or digest) - The summary data formats contain the following data points: + The summary data formats contain the following data points: - - Unique IP address count - - Pulls by tag count - - Pulls by digest count - - Version check count + - Unique IP address count + - Pulls by tag count + - Pulls by digest count + - Version check count - #### Raw data + #### Raw data - In your raw data CSV you will have access to the data points listed below. You can request raw data for a complete week (Monday through Sunday) or for a complete month (available on the first day of the following month). **Note:** each action is represented as a single row. - - - Type (industry) - - Host (cloud provider) - - Country (geolocation) - - Timestamp - - Namespace - - Repository - - Reference (digest is always included, tag is provided when available) - - HTTP request method - - Action, one of the following: - - Pull by tag - - Pull by digest - - Version check - - User-Agent + In your raw data CSV you will have access to the data points listed below. You can request raw data for a complete week (Monday through Sunday) or for a complete month (available on the first day of the following month). **Note:** each action is represented as a single row. + - Type (industry) + - Host (cloud provider) + - Country (geolocation) + - Timestamp + - Namespace + - Repository + - Reference (digest is always included, tag is provided when available) + - HTTP request method + - Action, one of the following: + - Pull by tag + - Pull by digest + - Version check + - User-Agent servers: - url: https://hub.docker.com/api/publisher/analytics/v1 security: - HubAuth: [] - -features.openapi: - schemaDefinitionsTagName: Schemas - tags: - name: authentication - x-displayName: Authentication Endpoints + summary: Authentication Endpoints + kind: nav + description: Authentication Endpoints reference. - name: namespaces - x-displayName: Namespace data + summary: Namespace data + kind: nav + description: Namespace data reference. - name: discovery - x-displayName: Discovery + summary: Discovery + kind: nav + description: Discovery reference. - name: responseDataFile - x-displayName: ResponseDataFile description: | - + [ResponseDataFile](#schema-ResponseDataFile) + summary: ResponseDataFile + kind: info - name: yearModel - x-displayName: Year Data Model description: | - + [YearModel](#schema-YearModel) + summary: Year Data Model + kind: info - name: monthModel - x-displayName: Month Data Model description: | - + [MonthModel](#schema-MonthModel) + summary: Month Data Model + kind: info - name: weekModel - x-displayName: Week Data Model description: | - - + [WeekModel](#schema-WeekModel) + summary: Week Data Model + kind: info x-tagGroups: - name: API tags: @@ -87,10 +91,8 @@ x-tagGroups: - yearModel - monthModel - weekModel - paths: /v2/users/login: - security: [] servers: - url: https://hub.docker.com post: @@ -114,20 +116,19 @@ paths: description: Login details. required: true responses: - 200: + "200": description: Authentication successful content: application/json: schema: $ref: "#/components/schemas/PostUsersLoginSuccessResponse" - 401: + "401": description: Authentication failed or second factor required content: application/json: schema: $ref: "#/components/schemas/PostUsersLoginErrorResponse" /v2/users/2fa-login: - security: [] servers: - url: https://hub.docker.com post: @@ -153,53 +154,73 @@ paths: description: Login details. required: true responses: - 200: + "200": description: Authentication successful content: application/json: schema: $ref: "#/components/schemas/PostUsersLoginSuccessResponse" - 401: + "401": description: Authentication failed or second factor required content: application/json: schema: $ref: "#/components/schemas/PostUsers2FALoginErrorResponse" - - /: get: - tags: [discovery] + tags: + - discovery summary: Get namespaces and repos description: Gets a list of your namespaces and repos which have data available. operationId: getNamespaces responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/NamespaceData' + $ref: "#/components/schemas/NamespaceData" /namespaces: get: - tags: [discovery] + tags: + - discovery summary: Get user's namespaces description: Get metadata associated with the namespaces the user has access to, including extra repos associated with the namespaces. operationId: getUserNamespaces responses: - '200': + "200": description: Success content: application/json: schema: type: array items: - $ref: '#/components/schemas/NamespaceMetadata' - '401': + $ref: "#/components/schemas/NamespaceMetadata" + examples: + publishers: + summary: Namespace metadata + value: + - datasets: + - name: pulls + timespans: + - months + - weeks + views: + - raw + - summary + - repo-summary + - namespace-summary + - geo-repo-summary + extraRepos: null + namespace: org1 + publisherType: DVP + extensionPublisher: false + "401": description: Authentication failed or second factor required /namespaces/{namespace}: get: - tags: [discovery] + tags: + - discovery summary: Get namespace description: Gets metadata associated with specified namespace, including extra repos associated with the namespace. operationId: getNamespace @@ -211,15 +232,16 @@ paths: required: true description: Namespace to fetch data for responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/NamespaceMetadata' + $ref: "#/components/schemas/NamespaceMetadata" /namespaces/{namespace}/pulls: get: - tags: [namespaces] + tags: + - namespaces summary: Get pull data description: Gets pulls for the given namespace. operationId: getNamespacePulls @@ -233,33 +255,34 @@ paths: - in: query name: timespan schema: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" required: false description: Timespan type for fetching data - in: query name: period schema: - $ref: '#/components/schemas/PeriodType' + $ref: "#/components/schemas/PeriodType" required: false description: Relative period of the period to fetch data - in: query name: group schema: - $ref: '#/components/schemas/GroupType' + $ref: "#/components/schemas/GroupType" required: false description: Field to group the data by responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/PullData' - '404': + $ref: "#/components/schemas/PullData" + "404": description: Not found - namespace doesn't exist or user does not have permission to access it /namespaces/{namespace}/repos/{repo}/pulls: get: - tags: [namespaces] + tags: + - namespaces summary: Get pull data description: Gets pulls for the given repo. operationId: getRepoPulls @@ -279,33 +302,34 @@ paths: - in: query name: timespan schema: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" required: false description: Timespan type for fetching data - in: query name: period schema: - $ref: '#/components/schemas/PeriodType' + $ref: "#/components/schemas/PeriodType" required: false description: Relative period of the period to fetch data - in: query name: group schema: - $ref: '#/components/schemas/GroupType' + $ref: "#/components/schemas/GroupType" required: false description: Field to group the data by responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/PullData' - '404': + $ref: "#/components/schemas/PullData" + "404": description: Not found - repo doesn't exist or user does not have permission to access it /namespaces/{namespace}/pulls/exports/years: get: - tags: [namespaces] + tags: + - namespaces summary: Get years with data description: Gets a list of years that have data for the given namespace. operationId: getNamespaceYears @@ -317,15 +341,16 @@ paths: required: true description: Namespace to fetch data for responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/YearData' + $ref: "#/components/schemas/YearData" /namespaces/{namespace}/pulls/exports/years/{year}/{timespantype}: get: - tags: [namespaces] + tags: + - namespaces summary: Get timespans with data description: Gets a list of timespans of the given type that have data for the given namespace and year. operationId: getNamespaceTimespans @@ -345,19 +370,20 @@ paths: - in: path name: timespantype schema: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" required: true description: Type of timespan to fetch data for responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/TimespanData' + $ref: "#/components/schemas/TimespanData" /namespaces/{namespace}/pulls/exports/years/{year}/{timespantype}/{timespan}: get: - tags: [namespaces] + tags: + - namespaces summary: Get namespace metadata for timespan description: Gets info about data for the given namespace and timespan. operationId: getNamespaceTimespanMetadata @@ -377,7 +403,7 @@ paths: - in: path name: timespantype schema: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" required: true description: Type of timespan to fetch data for - in: path @@ -387,17 +413,18 @@ paths: required: true description: Timespan to fetch data for responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/TimespanModel' - '404': + $ref: "#/components/schemas/TimespanModel" + "404": description: Not Found /namespaces/{namespace}/pulls/exports/years/{year}/{timespantype}/{timespan}/{dataview}: get: - tags: [namespaces] + tags: + - namespaces summary: Get namespace data for timespan description: Gets a list of URLs that can be used to download the pull data for the given namespace and timespan. operationId: getNamespaceDataByTimespan @@ -417,7 +444,7 @@ paths: - in: path name: timespantype schema: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" required: true description: Type of timespan to fetch data for - in: path @@ -429,19 +456,28 @@ paths: - in: path name: dataview schema: - $ref: '#/components/schemas/DataviewType' + $ref: "#/components/schemas/DataviewType" required: true description: Type of data to fetch responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/ResponseData' + $ref: "#/components/schemas/ResponseData" + examples: + download: + summary: Illustrative download location + description: Use the signed URL returned by the API. The URL and file size in this example are illustrative. + value: + data: + - url: https://example.invalid/exports/pulls.csv.gz?X-Amz-Expires=21600&X-Amz-Signature=EXAMPLE + size: 1024 /repos/pulls: get: - tags: [namespaces] + tags: + - namespaces summary: Get pull data for multiple repos description: Gets pull for the given repos. operationId: getManyReposPulls @@ -457,32 +493,34 @@ paths: - in: query name: timespan schema: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" required: false description: Timespan type for fetching data - in: query name: period schema: - $ref: '#/components/schemas/PeriodType' + $ref: "#/components/schemas/PeriodType" required: false description: Relative period of the period to fetch data - in: query name: group schema: - $ref: '#/components/schemas/GroupType' + $ref: "#/components/schemas/GroupType" required: false description: Field to group the data by responses: - '200': + "200": description: Success content: application/json: schema: - $ref: '#/components/schemas/ReposPullData' - + $ref: "#/components/schemas/ReposPullData" components: schemas: UsersLoginRequest: + examples: + - username: myusername + password: hunter2 description: User login details type: object required: @@ -494,12 +532,12 @@ components: type: string example: myusername password: - description: - The password or personal access token (PAT) of the Docker Hub - account to authenticate with. + description: The password or personal access token (PAT) of the Docker Hub account to authenticate with. type: string example: hunter2 PostUsersLoginSuccessResponse: + examples: + - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c description: successful user login response type: object properties: @@ -510,8 +548,9 @@ components: This token can be used in the HTTP Authorization header as a JWT to authenticate with the Docker Hub APIs. type: string example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c - nullable: false PostUsersLoginErrorResponse: + examples: + - detail: Incorrect authentication credentials description: failed user login response or second factor required type: object required: @@ -521,16 +560,16 @@ components: description: Description of the error. type: string example: Incorrect authentication credentials - nullable: false login_2fa_token: - description: - Short-lived token to be used on `/v2/users/2fa-login` to - complete the authentication. This field is present only if 2FA is - enabled. - type: string + description: Short-lived token to be used on `/v2/users/2fa-login` to complete the authentication. This field is present only if 2FA is enabled. + type: + - string + - "null" example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c - nullable: true Users2FALoginRequest: + examples: + - login_2fa_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c + code: "123456" description: Second factor user login details type: object required: @@ -542,12 +581,12 @@ components: type: string example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c code: - description: - The Time-based One-Time Password of the Docker Hub account to - authenticate with. + description: The Time-based One-Time Password of the Docker Hub account to authenticate with. type: string - example: 123456 + example: "123456" PostUsers2FALoginErrorResponse: + examples: + - detail: Incorrect authentication credentials description: failed second factor login response. type: object properties: @@ -555,138 +594,299 @@ components: description: Description of the error. type: string example: Incorrect authentication credentials - nullable: false - ResponseData: properties: data: type: array - description: | - List of urls to download the data. When the data is large, the data will be split into multiple files. - items: - $ref: '#/components/schemas/ResponseDataFile' + description: Download URLs and file sizes for the export. + items: + $ref: "#/components/schemas/ResponseDataFile" + type: object + description: Download locations for the selected export. Large exports can span multiple files. ResponseDataFile: properties: url: type: string + format: uri + description: Signed download URL. URLs expire six hours after generation. size: type: integer format: int64 + description: Size of the file in bytes. + type: object NamespaceData: + examples: + - namespaces: + - myorganization properties: namespaces: type: array items: type: string + type: object NamespaceMetadata: properties: namespace: type: string extraRepos: - type: array + type: + - array + - "null" items: type: string + description: Additional repositories associated with the namespace. Null when none are configured. datasets: type: array items: - $ref: '#/components/schemas/DatasetModel' + $ref: "#/components/schemas/DatasetModel" + publisherType: + type: string + description: Publisher program associated with the namespace. + extensionPublisher: + type: boolean + description: Whether the namespace is an extension publisher. + type: object + examples: + - datasets: + - name: pulls + timespans: + - months + - weeks + views: + - raw + - summary + - repo-summary + - namespace-summary + - geo-repo-summary + extraRepos: null + namespace: org1 + publisherType: DVP + extensionPublisher: false + - datasets: + - name: pulls + timespans: + - months + - weeks + views: + - repo-summary + - namespace-summary + - name: extensions + timespans: + - months + - weeks + views: + - ext-summary + extraRepos: null + namespace: org4 + publisherType: DVP + extensionPublisher: true DatasetModel: properties: name: - $ref: '#/components/schemas/DatasetType' + $ref: "#/components/schemas/DatasetType" views: type: array items: - $ref: '#/components/schemas/DataviewType' + $ref: "#/components/schemas/DataviewType" timespans: type: array items: - $ref: '#/components/schemas/TimespanType' + $ref: "#/components/schemas/TimespanType" + type: object + description: Datasets and views available to the publisher, based on its entitlements. PullData: properties: pulls: - type: array + type: + - array + - "null" items: - $ref: '#/components/schemas/PullModel' + $ref: "#/components/schemas/PullModel" + description: Pull statistics for the selected interval. Null when no records are returned. + type: object + examples: + - pulls: + - end: "2022-08-07T00:00:00Z" + pullCount: 11 + repo: neo4j-admin + start: "2022-08-01T00:00:00Z" + ipCount: 10 + - end: "2022-08-14T00:00:00Z" + pullCount: 11 + repo: neo4j-admin + start: "2022-08-08T00:00:00Z" + ipCount: 10 + - pulls: null ReposPullData: properties: repos: type: object additionalProperties: - $ref: '#/components/schemas/PullData' + $ref: "#/components/schemas/PullData" + type: object + examples: + - repos: + org2/neo4j-admin: + pulls: + - end: "2022-08-07T00:00:00Z" + pullCount: 11 + repo: neo4j-admin + start: "2022-08-01T00:00:00Z" + ipCount: 10 + - end: "2022-08-14T00:00:00Z" + pullCount: 11 + repo: neo4j-admin + start: "2022-08-08T00:00:00Z" + ipCount: 10 + org2/neo4j: + pulls: + - end: "2022-08-07T00:00:00Z" + pullCount: 272407 + repo: neo4j + start: "2022-08-01T00:00:00Z" + ipCount: 41490 + - end: "2022-08-14T00:00:00Z" + pullCount: 272407 + repo: neo4j + start: "2022-08-08T00:00:00Z" + ipCount: 41490 PullModel: properties: start: type: string + format: date-time + description: Start of the reporting interval in RFC 3339 format. end: type: string + format: date-time + description: End of the reporting interval in RFC 3339 format. repo: type: string + description: Repository name. Present for results grouped by repository. namespace: type: string + description: Namespace name. Present for results grouped by namespace. pullCount: type: integer ipCount: type: integer country: type: string - + description: Country. Present for results grouped by geography and repository. + type: object YearData: + examples: + - years: + - year: 2025 properties: years: type: array items: - $ref: '#/components/schemas/YearModel' + $ref: "#/components/schemas/YearModel" + type: object YearModel: properties: year: type: integer + type: object MonthData: properties: months: type: array items: - $ref: '#/components/schemas/MonthModel' + $ref: "#/components/schemas/MonthModel" + type: object + required: + - months + examples: + - months: + - month: 5 + - month: 7 MonthModel: properties: month: type: integer + type: object + required: + - month + examples: + - month: 7 WeekData: properties: weeks: type: array items: - $ref: '#/components/schemas/WeekModel' + $ref: "#/components/schemas/WeekModel" + type: object + required: + - weeks + examples: + - weeks: + - week: 31 + - week: 32 WeekModel: properties: week: type: integer + type: object + required: + - week + examples: + - week: 31 TimespanType: type: string - enum: [months,weeks] + enum: + - months + - weeks PeriodType: type: string - enum: [last-2-months,last-3-months,last-6-months,last-12-months] + enum: + - last-2-months + - last-3-months + - last-6-months + - last-12-months DataviewType: type: string - enum: [raw,summary,repo-summary,namespace-summary] + enum: + - raw + - summary + - repo-summary + - namespace-summary + - geo-repo-summary + - ext-summary + - ext-premium-summary + - ext-premium-geo DatasetType: type: string - enum: [pulls] + enum: + - pulls + - extensions TimespanModel: oneOf: - - $ref: '#/components/schemas/MonthModel' - - $ref: '#/components/schemas/WeekModel' + - $ref: "#/components/schemas/MonthModel" + - $ref: "#/components/schemas/WeekModel" + examples: + - month: 7 + - week: 31 TimespanData: oneOf: - - $ref: '#/components/schemas/MonthData' - - $ref: '#/components/schemas/WeekData' + - $ref: "#/components/schemas/MonthData" + - $ref: "#/components/schemas/WeekData" + examples: + - months: + - month: 5 + - month: 7 + - weeks: + - week: 31 + - week: 32 GroupType: type: string - enum: [repo,namespace] + enum: + - repo + - namespace securitySchemes: HubAuth: - type: https + type: http scheme: bearer bearerFormat: JWT description: | @@ -694,3 +894,6 @@ components: This authentication documentation is duplicated from the [Hub API Authentication docs](https://docs.docker.com/reference/api/hub/latest/#tag/authentication) x-displayName: Docker Hub Authentication +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +x-features-openapi: + schemaDefinitionsTagName: Schemas diff --git a/content/reference/api/hub/latest.md b/content/reference/api/hub/latest.md deleted file mode 100644 index 15fd57a3db7f..000000000000 --- a/content/reference/api/hub/latest.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -layout: api -description: Reference documentation and Swagger (OpenAPI) specification for the Docker Hub API. -title: Docker Hub API reference -linkTitle: Latest -weight: 1 ---- diff --git a/content/reference/api/hub/latest.yaml b/content/reference/api/hub/latest.yaml index 3ed32a2c6302..05ce1e7e3de6 100644 --- a/content/reference/api/hub/latest.yaml +++ b/content/reference/api/hub/latest.yaml @@ -1,6 +1,4 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/OAI/OpenAPI-Specification/refs/heads/main/schemas/v3.0/schema.yaml - -openapi: 3.0.3 +openapi: 3.2.0 info: title: Docker HUB API version: 2-beta @@ -21,16 +19,17 @@ servers: url: https://hub.docker.com tags: - name: changelog - x-displayName: Changelog description: | See the [Changelog](/reference/api/hub/changelog) for a summary of changes across Docker Hub API versions. + summary: Changelog + kind: info - name: resources - x-displayName: Resources description: | The following resources are available to interact with the documented API: - [Docker Hub CLI tool](https://github.com/docker/hub-tool#readme) (currently experimental) + summary: Resources + kind: info - name: rate-limiting - x-displayName: Rate Limiting description: | The Docker Hub API is limited on the amount of requests you can perform per minute against it. @@ -46,9 +45,10 @@ tags: **Note**: These rate limits are separate from anti-abuse and Docker Hub download, or pull rate limiting. To learn more about Docker Hub pull rate limiting, see [Usage and limits](https://docs.docker.com/docker-hub/usage/). + summary: Rate Limiting + kind: info - name: authentication - x-displayName: Authentication - description: | + description: |+ Most Docker Hub API endpoints require you to authenticate using your Docker credentials before using them. Additionally, similar to the Docker Hub UI features, API endpoint responses may vary depending on your subscription (Personal, Pro, or Team) and your account's permissions. @@ -90,16 +90,18 @@ tags: These labels will show up on routes in this reference that allow for use of bearer tokens issued from them. - - + + + summary: Authentication + kind: info - name: authentication-api - x-displayName: Authentication description: | The authentication endpoints allow you to authenticate with Docker Hub APIs. For more information, see [Authentication](#tag/authentication). + summary: Authentication + kind: nav - name: access-tokens - x-displayName: Personal Access Tokens description: | The Personal Access Token endpoints lets you manage personal access tokens. For more information, see [Access Tokens](https://docs.docker.com/security/access-tokens/personal-access-tokens/). @@ -112,50 +114,58 @@ tags: If you were to define both `repo:write` *and* `repo:read`, then `repo:read` is assumed by `repo:write` and ignored. ***Treat your personal access token like your password and keep it secret. You cannot retrieve your token after it is generated.*** + summary: Personal Access Tokens + kind: nav - name: audit-logs - x-displayName: Audit Logs description: | The Audit Logs API endpoints allow you to query audit log events across a namespace. For more information, see [Audit Logs](https://docs.docker.com/admin/activity-logs/). + summary: Audit Logs + kind: nav - name: org-settings - x-displayName: Org Settings description: | The Org Settings API endpoints allow you to manage your organization's settings. + summary: Org Settings + kind: nav - name: repositories - x-displayName: Repositories description: | The repository endpoints allow you to access your repository's tags. + summary: Repositories + kind: nav - name: orgs - x-displayName: Organizations x-audience: public description: | The organization endpoints allow you to interact with and manage your organizations. For more information, see [Organization administration overview](https://docs.docker.com/admin/organization/). + summary: Organizations + kind: nav - name: groups - x-displayName: Groups (Teams) x-audience: public description: | The groups endpoints allow you to manage your organization's teams and their members. For more information, see [Create and manage a team](https://docs.docker.com/admin/organization/manage/manage-a-team/). + summary: Groups (Teams) + kind: nav - name: invites - x-displayName: Invites x-audience: public description: | The invites endpoints allow you to manage invites for users to join your Docker organization. For more information, see [Invite members](https://docs.docker.com/admin/organization/manage/members/#invite-members). + summary: Invites + kind: nav - name: scim - x-displayName: SCIM x-audience: public description: | SCIM is a provisioning system that lets you manage users within your identity provider (IdP). For more information, see [System for Cross-domain Identity management](https://docs.docker.com/security/for-admins/provisioning/scim/). + summary: SCIM + kind: nav - name: org-access-tokens - x-displayName: Organization Access Tokens x-audience: public description: | The organization access token endpoints allow you to manage organization access tokens (OATs). See [Organization access tokens](https://docs.docker.com/security/for-admins/access-tokens/) for more information. @@ -165,6 +175,8 @@ tags: - `GET /v2/repositories/{namespace}/{repository}` — use [Get repository](#tag/repositories/operation/GetRepository) instead. - `GET /v2/repositories/{namespace}` — use [List repositories](#tag/repositories/operation/listNamespaceRepositories) instead. - `GET /v2/users/{username}/repositories` — use [List repositories](#tag/repositories/operation/listNamespaceRepositories) instead. + summary: Organization Access Tokens + kind: nav paths: /v2/users/login: post: @@ -174,16 +186,16 @@ paths: operationId: PostUsersLogin security: [] deprecated: true - description: | + description: |+ Creates and returns a bearer token in JWT format that you can use to authenticate with Docker Hub APIs. The returned token is used in the HTTP Authorization header like `Authorization: Bearer {TOKEN}`. _**As of September 16, 2024, this route requires a personal access token (PAT) instead of a password if your organization has SSO enforced.**_ -
- Deprecated: Use [Create access token] instead. -
+ + Deprecated: Use [Create access token](#tag/authentication-api/operation/AuthCreateAccessToken) instead. + requestBody: content: application/json: @@ -257,6 +269,9 @@ paths: content: application/json: schema: + examples: + - identifier: myusername + secret: dckr_pat_124509ugsdjga93 description: Request to create access token type: object required: @@ -310,6 +325,7 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + operationId: postV2AccessTokens get: summary: List personal access tokens description: Returns a paginated list of personal access tokens. @@ -318,12 +334,14 @@ paths: security: - bearerAuth: [] parameters: - - in: query + - description: Page number to return. + in: query name: page schema: type: number default: 1 - - in: query + - description: Number of results per page. + in: query name: page_size schema: type: number @@ -339,9 +357,11 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + operationId: getV2AccessTokens /v2/access-tokens/{uuid}: parameters: - - in: path + - description: UUID of the personal access token. + in: path name: uuid required: true schema: @@ -371,6 +391,7 @@ paths: $ref: "#/components/responses/BadRequest" "401": $ref: "#/components/responses/Unauthorized" + operationId: patchV2AccessTokensByUuid get: summary: Get personal access token description: Returns a personal access token by UUID. @@ -384,17 +405,32 @@ paths: content: application/json: schema: + examples: + - uuid: b30bbf97-506c-4ecd-aabc-842f3cb484fb + client_id: HUB + creator_ip: 127.0.0.1 + creator_ua: some user agent + created_at: "2021-07-20T12:00:00.000000Z" + last_used: null + generated_by: manual + is_active: true + token: "***" + token_label: My read only token + scopes: + - repo:read + expires_at: "2021-10-28T18:30:19.520861Z" allOf: - $ref: "#/components/schemas/accessToken" - type: object properties: token: type: string - example: "" + example: "***" "401": $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" + operationId: getV2AccessTokensByUuid delete: summary: Delete personal access token description: | @@ -410,13 +446,14 @@ paths: $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" + operationId: deleteV2AccessTokensByUuid /v2/auditlogs/{account}/actions: get: summary: List audit log actions - description: | + description: |+ List audit log actions for a namespace to be used as a filter for querying audit log events. - + operationId: AuditLogs_ListAuditActions security: - bearerAuth: [] @@ -688,13 +725,26 @@ paths: description: "" content: application/json: - schema: {} + schema: + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: internal error + errinfo: null default: description: An unexpected error response. content: application/json: schema: - $ref: "#/components/schemas/rpcStatus" + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: internal error + errinfo: null parameters: - name: account description: Namespace to query audit log actions for. @@ -707,10 +757,10 @@ paths: /v2/auditlogs/{account}: get: summary: List audit log events - description: | + description: |+ List audit log events for a given namespace. - + operationId: AuditLogs_ListAuditLogs security: - bearerAuth: [] @@ -732,7 +782,7 @@ paths: data: digest: sha256:c1ae9c435032a276f80220c7d9b40f76266bbe79243d34f9cda30b76fe114dfa tag: latest - timestamp: "2021-02-19T01:34:35Z" + timestamp: 2021-02-19T01:34:35Z action_description: | pushed the tag latest with the digest sha256:c1ae9c435032a to the repository docker/example - account: docker @@ -742,12 +792,12 @@ paths: data: lease_id: l_3EgPuRCjtUqT279CFPOQWcO8zOf resource_type: run_4cpu_8mem - started_at: "2026-06-04T18:24:21Z" - updated_at: "2026-06-04T18:36:43Z" + started_at: 2026-06-04T18:24:21Z + updated_at: 2026-06-04T18:36:43Z org_id: b908ca6e-b9a9-4a53-a9a5-6bec96f72432 user_id: ecae6747-e42c-43cb-925d-cfce1ab32b02 - timestamp: "2026-06-04T18:36:43Z" - action_description: "offload lease 'l_3EgPuRCjtUqT279CFPOQWcO8zOf' ended, ran for '12m22s'" + timestamp: 2026-06-04T18:36:43Z + action_description: offload lease 'l_3EgPuRCjtUqT279CFPOQWcO8zOf' ended, ran for '12m22s' "429": description: "" content: @@ -762,13 +812,26 @@ paths: description: "" content: application/json: - schema: {} + schema: + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: internal error + errinfo: null default: description: An unexpected error response. content: application/json: schema: - $ref: "#/components/schemas/rpcStatus" + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: internal error + errinfo: null parameters: - name: account description: Namespace to query audit logs for. @@ -858,6 +921,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: getV2OrgsByNameSettings put: summary: Update organization settings description: | @@ -875,6 +939,11 @@ paths: content: application/json: schema: + examples: + - restricted_images: + enabled: true + allow_official_images: true + allow_verified_publishers: true required: - restricted_images properties: @@ -900,6 +969,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: putV2OrgsByNameSettings /v2/orgs/{name}/access-tokens: post: summary: Create access token @@ -930,6 +1000,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: postV2OrgsByNameAccessTokens get: summary: List access tokens description: | @@ -939,12 +1010,14 @@ paths: security: - bearerAuth: [] parameters: - - in: query + - description: Page number to return. + in: query name: page schema: type: number default: 1 - - in: query + - description: Number of results per page. + in: query name: page_size schema: type: number @@ -962,7 +1035,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" - + operationId: getV2OrgsByNameAccessTokens /v2/orgs/{org_name}/access-tokens/{access_token_id}: parameters: - $ref: "#/components/parameters/org_name" @@ -972,7 +1045,7 @@ paths: schema: type: string description: The ID of the access token to retrieve - example: "a7a5ef25-8889-43a0-8cc7-f2a94268e861" + example: a7a5ef25-8889-43a0-8cc7-f2a94268e861 get: summary: Get access token description: | @@ -994,6 +1067,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: getV2OrgsByOrgNameAccessTokensByAccessTokenId patch: summary: Update access token description: | @@ -1021,6 +1095,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: patchV2OrgsByOrgNameAccessTokensByAccessTokenId delete: summary: Delete access token description: | @@ -1038,6 +1113,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: deleteV2OrgsByOrgNameAccessTokensByAccessTokenId /v2/namespaces/{namespace}/repositories/{repository}/tags: parameters: - $ref: "#/components/parameters/namespace" @@ -1045,10 +1121,10 @@ paths: get: operationId: ListRepositoryTags summary: List repository tags - description: | + description: |+ Returns the list of tags for the specified repository. - + tags: - repositories security: @@ -1075,10 +1151,10 @@ paths: $ref: "#/components/responses/NotFound" head: summary: Check repository tags - description: | + description: |+ Checks whether the repository has any tags. - + tags: - repositories security: @@ -1090,6 +1166,7 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: headV2NamespacesByNamespaceRepositoriesByRepositoryTags /v2/namespaces/{namespace}/repositories/{repository}/tags/{tag}: parameters: - $ref: "#/components/parameters/namespace" @@ -1098,10 +1175,10 @@ paths: get: operationId: GetRepositoryTag summary: Read repository tag - description: | + description: |+ Returns details for a specific tag in the specified repository. - + tags: - repositories security: @@ -1115,10 +1192,10 @@ paths: $ref: "#/components/responses/NotFound" head: summary: Check repository tag - description: | + description: |+ Checks whether the specified tag exists in the repository. - + tags: - repositories security: @@ -1130,19 +1207,20 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + operationId: headV2NamespacesByNamespaceRepositoriesByRepositoryTagsByTag /v2/namespaces/{namespace}/repositories/{repository}/immutabletags: parameters: - $ref: "#/components/parameters/namespace" - $ref: "#/components/parameters/repository" patch: operationId: UpdateRepositoryImmutableTags - summary: "Update repository immutable tags" - description: | + summary: Update repository immutable tags + description: |+ Updates the immutable tags configuration for this repository. **Only users with administrative privileges for the repository can modify these settings.** - + tags: - repositories security: @@ -1150,15 +1228,15 @@ paths: requestBody: $ref: "#/components/requestBodies/update_repository_immutable_tags_request" responses: - 200: + "200": $ref: "#/components/responses/update_repository_immutable_tags_response" - 400: + "400": $ref: "#/components/responses/bad_request" - 401: + "401": $ref: "#/components/responses/unauthorized" - 403: + "403": $ref: "#/components/responses/forbidden" - 404: + "404": $ref: "#/components/responses/not_found" /v2/namespaces/{namespace}/repositories/{repository}/immutabletags/verify: parameters: @@ -1166,13 +1244,13 @@ paths: - $ref: "#/components/parameters/repository" post: operationId: VerifyRepositoryImmutableTags - summary: "Verify repository immutable tags" - description: | + summary: Verify repository immutable tags + description: |+ Validates the immutable tags regex passed in the request body and returns a list of tags matching it in this repository. **Only users with administrative privileges for the repository can call this endpoint.** - + tags: - repositories security: @@ -1180,15 +1258,15 @@ paths: requestBody: $ref: "#/components/requestBodies/immutable_tags_verify_request" responses: - 200: + "200": $ref: "#/components/responses/immutable_tags_verify_response" - 400: + "400": $ref: "#/components/responses/bad_request" - 401: + "401": $ref: "#/components/responses/unauthorized" - 403: + "403": $ref: "#/components/responses/forbidden" - 404: + "404": $ref: "#/components/responses/not_found" /v2/repositories/{namespace}/{repository}/groups: parameters: @@ -1196,10 +1274,10 @@ paths: - $ref: "#/components/parameters/repository" post: summary: Assign a group (Team) to a repository for access - description: | + description: |+ Assigns an organization group (team) to a repository with a specified permission level. - + tags: - repositories operationId: CreateRepositoryGroup @@ -1213,7 +1291,7 @@ paths: $ref: "#/components/schemas/RepositoryGroupCreationRequest" example: group_id: 12345 - permission: "write" + permission: write responses: "200": description: Repository group permission created successfully @@ -1222,8 +1300,8 @@ paths: schema: $ref: "#/components/schemas/RepositoryGroup" example: - group_name: "developers" - permission: "write" + group_name: developers + permission: write group_id: 12345 "400": description: Bad Request - Invalid request parameters @@ -1231,6 +1309,12 @@ paths: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: bad request + errinfo: null "401": $ref: "#/components/responses/unauthorized" "403": @@ -1248,14 +1332,14 @@ paths: Public repositories are accessible to everyone, while private repositories require appropriate authentication and permissions. - + **OAT listing behavior**: an OAT with the `scope-repository-list` scope sees all repositories including private ones. An OAT without that scope only sees public repositories. This filtering is silent: the response is a normal `200` with no indication that private repositories were withheld. tags: - repositories security: - bearerAuth: [] - - {} # Allow anonymous access for public repositories + - {} parameters: - in: query name: page @@ -1309,59 +1393,59 @@ paths: repositories_list: value: count: 287 - next: "https://hub.docker.com/v2/namespaces/docker/repositories?page=2&page_size=2" + next: https://hub.docker.com/v2/namespaces/docker/repositories?page=2&page_size=2 previous: null results: - - name: "highland_builder" - namespace: "docker" - repository_type: "image" + - name: highland_builder + namespace: docker + repository_type: image status: 1 - status_description: "active" - description: "Image for performing Docker build requests" + status_description: active + description: Image for performing Docker build requests is_private: false star_count: 7 pull_count: 15722123 - last_updated: "2023-06-20T10:44:45.459826Z" - last_modified: "2024-10-16T13:48:34.145251Z" - date_registered: "2015-05-19T21:13:35.937763Z" + last_updated: 2023-06-20T10:44:45.459826Z + last_modified: 2024-10-16T13:48:34.145251Z + date_registered: 2015-05-19T21:13:35.937763Z affiliation: "" media_types: - - "application/octet-stream" - - "application/vnd.docker.container.image.v1+json" - - "application/vnd.docker.distribution.manifest.v1+prettyjws" + - application/octet-stream + - application/vnd.docker.container.image.v1+json + - application/vnd.docker.distribution.manifest.v1+prettyjws content_types: - - "unrecognized" - - "image" + - unrecognized + - image categories: - - name: "Languages & frameworks" - slug: "languages-and-frameworks" - - name: "Integration & delivery" - slug: "integration-and-delivery" - - name: "Operating systems" - slug: "operating-systems" + - name: Languages & frameworks + slug: languages-and-frameworks + - name: Integration & delivery + slug: integration-and-delivery + - name: Operating systems + slug: operating-systems storage_size: 488723114800 - - name: "whalesay" - namespace: "docker" + - name: whalesay + namespace: docker repository_type: null status: 1 - status_description: "active" - description: "An image for use in the Docker demo tutorial" + status_description: active + description: An image for use in the Docker demo tutorial is_private: false star_count: 757 pull_count: 130737682 - last_updated: "2015-06-19T19:06:27.388123Z" - last_modified: "2024-10-16T13:48:34.145251Z" - date_registered: "2015-06-09T18:16:36.527329Z" + last_updated: 2015-06-19T19:06:27.388123Z + last_modified: 2024-10-16T13:48:34.145251Z + date_registered: 2015-06-09T18:16:36.527329Z affiliation: "" media_types: - - "application/vnd.docker.distribution.manifest.v1+prettyjws" + - application/vnd.docker.distribution.manifest.v1+prettyjws content_types: - - "image" + - image categories: - - name: "Languages & frameworks" - slug: "languages-and-frameworks" - - name: "Integration & delivery" - slug: "integration-and-delivery" + - name: Languages & frameworks + slug: languages-and-frameworks + - name: Integration & delivery + slug: integration-and-delivery storage_size: 103666708 "400": description: Bad Request - Invalid request parameters @@ -1375,10 +1459,8 @@ paths: value: fields: ordering: - [ - "Invalid ordering value. Must be one of: name, -name, last_updated, -last_updated, pull_count, -pull_count", - ] - text: "Invalid ordering value" + - "Invalid ordering value. Must be one of: name, -name, last_updated, -last_updated, pull_count, -pull_count" + text: Invalid ordering value "401": $ref: "#/components/responses/unauthorized" "403": @@ -1389,13 +1471,19 @@ paths: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: not found + errinfo: null post: summary: Create a new repository - description: | + description: |+ Creates a new repository within the specified namespace. The repository will be created with the provided metadata including name, description, and privacy settings. - + operationId: CreateRepository tags: - repositories @@ -1408,26 +1496,26 @@ paths: schema: $ref: "#/components/schemas/repo_creation_request" example: - name: "my-app" - namespace: "myorganization" - description: "A sample application repository" - full_description: "This is a comprehensive description of my application repository that contains additional details about the project." - registry: "docker.io" + name: my-app + namespace: myorganization + description: A sample application repository + full_description: This is a comprehensive description of my application repository that contains additional details about the project. + registry: docker.io is_private: false responses: - 201: + "201": description: Repository created successfully content: application/json: schema: $ref: "#/components/schemas/repository_info" example: - name: "my-app" - namespace: "myorganization" - repository_type: "image" + name: my-app + namespace: myorganization + repository_type: image status: 1 - status_description: "Active" - description: "A sample application repository" + status_description: Active + description: A sample application repository is_private: false is_automated: false star_count: 0 @@ -1435,9 +1523,9 @@ paths: last_updated: "2025-01-20T10:30:00Z" date_registered: "2025-01-20T10:30:00Z" collaborator_count: 0 - hub_user: "myorganization" + hub_user: myorganization has_starred: false - full_description: "This is a comprehensive description of my application repository that contains additional details about the project." + full_description: This is a comprehensive description of my application repository that contains additional details about the project. media_types: [] content_types: [] categories: [] @@ -1446,15 +1534,20 @@ paths: rules: [] storage_size: null source: null - 400: + user: myorganization + permissions: + read: true + write: true + admin: true + "400": $ref: "#/components/responses/bad_request" - 401: + "401": $ref: "#/components/responses/unauthorized" - 403: + "403": $ref: "#/components/responses/forbidden" - 404: + "404": $ref: "#/components/responses/not_found" - 500: + "500": $ref: "#/components/responses/internal_error" /v2/namespaces/{namespace}/repositories/{repository}: parameters: @@ -1463,30 +1556,30 @@ paths: get: operationId: GetRepository summary: Get repository in a namespace - description: | + description: |+ Returns a repository within the specified namespace (organization or user). Public repositories are accessible to everyone, while private repositories require appropriate authentication and permissions. - + tags: - repositories security: - bearerAuth: [] - - {} # Allow anonymous access for public repositories + - {} responses: - 200: + "200": content: application/json: schema: $ref: "#/components/schemas/repository_info" example: - name: "my-app" - namespace: "myorganization" - repository_type: "image" + name: my-app + namespace: myorganization + repository_type: image status: 1 - status_description: "Active" - description: "A sample application repository" + status_description: Active + description: A sample application repository is_private: false is_automated: false star_count: 0 @@ -1494,9 +1587,9 @@ paths: last_updated: "2025-01-20T10:30:00Z" date_registered: "2025-01-20T10:30:00Z" collaborator_count: 0 - hub_user: "myorganization" + hub_user: myorganization has_starred: false - full_description: "This is a comprehensive description of my application repository that contains additional details about the project." + full_description: This is a comprehensive description of my application repository that contains additional details about the project. media_types: [] content_types: [] categories: [] @@ -1505,66 +1598,42 @@ paths: rules: [] storage_size: null source: null - 401: + user: myorganization + permissions: + read: true + write: true + admin: true + "401": $ref: "#/components/responses/unauthorized" - 403: + "403": $ref: "#/components/responses/forbidden" - 404: + "404": $ref: "#/components/responses/not_found" - 500: + "500": $ref: "#/components/responses/internal_error" head: operationId: CheckRepository summary: Check repository in a namespace - description: | + description: |+ Check a repository within the specified namespace (organization or user). Public repositories are accessible to everyone, while private repositories require appropriate authentication and permissions. - + tags: - repositories security: - bearerAuth: [] - - {} # Allow anonymous access for public repositories + - {} responses: - 200: - content: - application/json: - schema: - $ref: "#/components/schemas/repository_info" - example: - name: "my-app" - namespace: "myorganization" - repository_type: "image" - status: 1 - status_description: "Active" - description: "A sample application repository" - is_private: false - is_automated: false - star_count: 0 - pull_count: 0 - last_updated: "2025-01-20T10:30:00Z" - date_registered: "2025-01-20T10:30:00Z" - collaborator_count: 0 - hub_user: "myorganization" - has_starred: false - full_description: "This is a comprehensive description of my application repository that contains additional details about the project." - media_types: [] - content_types: [] - categories: [] - immutable_tags_settings: - enabled: false - rules: [] - storage_size: null - source: null - 401: + "200": {} + "401": $ref: "#/components/responses/unauthorized" - 403: + "403": $ref: "#/components/responses/forbidden" - 404: + "404": $ref: "#/components/responses/not_found" - 500: + "500": $ref: "#/components/responses/internal_error" /v2/orgs/{org_name}/members: parameters: @@ -1577,7 +1646,7 @@ paths: - $ref: "#/components/parameters/role" get: summary: List org members - description: | + description: |+ Returns a list of members for an organization. _The following fields are only visible to orgs with insights enabled._ @@ -1588,7 +1657,7 @@ paths: To make visible, please see [View Insights for organization users](https://docs.docker.com/admin/insights/#view-insights-for-organization-users). - + tags: - orgs security: @@ -1599,9 +1668,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/org_member_paginated" + $ref: "#/components/schemas/org_member_paginated" "400": $ref: "#/components/responses/bad_request" "401": @@ -1610,15 +1677,16 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: getV2OrgsByOrgNameMembers /v2/orgs/{org_name}/members/export: parameters: - $ref: "#/components/parameters/org_name" get: summary: Export org members CSV - description: | + description: |+ Export members of an organization as a CSV - + tags: - orgs security: @@ -1629,46 +1697,11 @@ paths: content: text/csv: schema: - type: array - items: - type: object - required: - - Name - - Username - - Email - - Type - - Role - - Date Joined - properties: - Name: - type: string - description: First and last name of the member - Username: - type: string - description: Username of the member - Email: - type: string - description: Email address of the member - Type: - type: string - description: Type of the member - enum: - - Invitee - - User - Permission: - type: string - description: Permission of the member - enum: - - Owner - - Member - Teams: - type: string - description: Comma-separated list of teams the member is part of - example: team-1, team-2 - Date Joined: - type: string - description: Date the member joined the organization - example: 2020-01-01 15:00:51.193355 +0000 UTC + type: string + description: "CSV columns: Name, Username, Email, Type, Role, Teams, and Date Joined. The Role column contains the assigned role label. When administrative insights are available, the export also includes Last Logged-in Date, Docker Desktop Version, Last Seen Date, and Opted Out Analytics." + example: | + Name,Username,Email,Type,Role,Teams,Date Joined + Jon Snow,dockeruser,example@docker.com,User,Owner,owners,2021-01-05 21:06:53.5064 +0000 UTC headers: Content-Disposition: schema: @@ -1682,6 +1715,7 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: getV2OrgsByOrgNameMembersExport /v2/orgs/{org_name}/members/{username}: x-audience: public parameters: @@ -1689,11 +1723,11 @@ paths: - $ref: "#/components/parameters/username" put: summary: Update org member (role) - description: | + description: |+ Updates the role of a member in the organization. ***Only users in the "owners" group of the organization can use this endpoint.*** - + tags: - orgs security: @@ -1703,6 +1737,8 @@ paths: content: application/json: schema: + examples: + - role: owner required: - role properties: @@ -1729,12 +1765,13 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: putV2OrgsByOrgNameMembersByUsername delete: summary: Remove member from org - description: | + description: |+ Removes the member from the org, ie. all groups in the org, unless they're the last owner - + tags: - orgs security: @@ -1750,16 +1787,17 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: deleteV2OrgsByOrgNameMembersByUsername /v2/orgs/{org_name}/invites: x-audience: public parameters: - $ref: "#/components/parameters/org_name" get: summary: List org invites - description: | + description: |+ Return all pending invites for a given org, only team owners can call this endpoint - + tags: - invites security: @@ -1770,6 +1808,14 @@ paths: content: application/json: schema: + examples: + - data: + - id: e36eca69-4cc8-4f17-9845-ae8c2b832691 + inviter_username: moby + invitee: invitee@docker.com + org: docker + team: owners + created_at: "2021-10-28T18:30:19.520861Z" type: object properties: data: @@ -1782,14 +1828,14 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: getV2OrgsByOrgNameInvites /v2/orgs/{org_name}/groups: x-audience: public parameters: - $ref: "#/components/parameters/org_name" get: summary: Get groups of an organization - description: | - + description: Get groups of an organization. tags: - groups security: @@ -1818,27 +1864,44 @@ paths: type: number example: 1 next: - type: string + type: + - string + - "null" example: null previous: - type: string + type: + - string + - "null" example: null results: type: array items: $ref: "#/components/schemas/org_group" + type: object + examples: + - count: 1 + next: null + previous: null + results: + - id: 10 + uuid: e36eca69-4cc8-4f17-9845-ae8c2b832691 + name: developers + description: Development team + member_count: 1 + role: member "401": $ref: "#/components/responses/unauthorized" "403": $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: getV2OrgsByOrgNameGroups post: summary: Create a new group - description: | + description: |+ Create a new group within an organization. - + tags: - groups security: @@ -1847,6 +1910,9 @@ paths: content: application/json: schema: + examples: + - name: developers + description: Development team required: - name properties: @@ -1867,6 +1933,7 @@ paths: $ref: "#/components/responses/unauthorized" "403": $ref: "#/components/responses/forbidden" + operationId: postV2OrgsByOrgNameGroups /v2/orgs/{org_name}/groups/{group_name}: x-audience: public parameters: @@ -1874,8 +1941,7 @@ paths: - $ref: "#/components/parameters/group_name" get: summary: Get a group of an organization - description: | - + description: Get a group of an organization. tags: - groups security: @@ -1893,10 +1959,10 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: getV2OrgsByOrgNameGroupsByGroupName put: summary: Update the details for an organization group - description: | - + description: Update the details for an organization group. tags: - groups security: @@ -1905,6 +1971,9 @@ paths: content: application/json: schema: + examples: + - name: developers + description: Development team required: - name properties: @@ -1925,10 +1994,10 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: putV2OrgsByOrgNameGroupsByGroupName patch: summary: Update some details for an organization group - description: | - + description: Update some details for an organization group. tags: - groups security: @@ -1937,6 +2006,9 @@ paths: content: application/json: schema: + examples: + - description: Development team + role: member properties: name: type: string @@ -1962,10 +2034,10 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: patchV2OrgsByOrgNameGroupsByGroupName delete: summary: Delete an organization group - description: | - + description: Delete an organization group. tags: - groups security: @@ -1979,6 +2051,7 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: deleteV2OrgsByOrgNameGroupsByGroupName /v2/orgs/{org_name}/groups/{group_name}/members: x-audience: public get: @@ -1995,11 +2068,11 @@ paths: type: string description: Search members by username, full_name or email. summary: List members of a group - description: | + description: |+ List the members (users) that are in a group. If user is owner of the org or has otherwise elevated permissions, they can search by email and the result will also contain emails. - + tags: - groups responses: @@ -2013,28 +2086,44 @@ paths: type: number example: 1 next: - type: string + type: + - string + - "null" example: null previous: - type: string + type: + - string + - "null" example: null results: type: array items: $ref: "#/components/schemas/group_member" + type: object + examples: + - count: 1 + next: null + previous: null + results: + - id: 0ab70deb065a43fcacd55d48caa945d8 + username: dockeruser + full_name: Jon Snow + type: User + date_joined: "2021-01-05T21:06:53.506400Z" + email: example@docker.com "401": $ref: "#/components/responses/unauthorized" "403": $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: getV2OrgsByOrgNameGroupsByGroupNameMembers post: parameters: - $ref: "#/components/parameters/org_name" - $ref: "#/components/parameters/group_name" summary: Add a member to a group - description: | - + description: Add a member to a group. tags: - groups security: @@ -2052,6 +2141,7 @@ paths: $ref: "#/components/responses/not_found" "500": $ref: "#/components/responses/internal_error" + operationId: postV2OrgsByOrgNameGroupsByGroupNameMembers /v2/orgs/{org_name}/groups/{group_name}/members/{username}: x-audience: public parameters: @@ -2060,8 +2150,7 @@ paths: - $ref: "#/components/parameters/username" delete: summary: Remove a user from a group - description: | - + description: Remove a user from a group. tags: - groups security: @@ -2075,20 +2164,22 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: deleteV2OrgsByOrgNameGroupsByGroupNameMembersByUsername /v2/invites/{id}: x-audience: public parameters: - - in: path + - description: ID of the invitation. + in: path name: id required: true schema: type: string delete: summary: Cancel an invite - description: | + description: |+ Mark the invite as cancelled so it doesn't show up on the list of pending invites - + tags: - invites security: @@ -2102,20 +2193,22 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: deleteV2InvitesById /v2/invites/{id}/resend: x-audience: public parameters: - - in: path + - description: ID of the invitation. + in: path name: id schema: type: string required: true patch: summary: Resend an invite - description: | + description: |+ Resend a pending invite to the user, any org owner can resend an invite - + tags: - invites security: @@ -2129,16 +2222,17 @@ paths: $ref: "#/components/responses/forbidden" "404": $ref: "#/components/responses/not_found" + operationId: patchV2InvitesByIdResend /v2/invites/bulk: x-audience: public parameters: - $ref: "#/components/parameters/bulk_invite" post: summary: Bulk create invites - description: | + description: |+ Create multiple invites by emails or DockerIDs. Only a team owner can create invites. - + tags: - invites requestBody: @@ -2151,14 +2245,12 @@ paths: content: application/json: schema: - type: object - properties: - invitees: - $ref: "#/components/schemas/bulk_invite" + $ref: "#/components/schemas/bulk_invite" "400": $ref: "#/components/responses/bad_request" "409": $ref: "#/components/responses/conflict" + operationId: postV2InvitesBulk /v2/scim/2.0/ServiceProviderConfig: x-audience: public get: @@ -2168,7 +2260,7 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] responses: "200": $ref: "#/components/responses/scim_get_service_provider_config_resp" @@ -2176,6 +2268,7 @@ paths: $ref: "#/components/responses/scim_unauthorized" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20ServiceProviderConfig /v2/scim/2.0/ResourceTypes: x-audience: public get: @@ -2185,7 +2278,7 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] responses: "200": $ref: "#/components/responses/scim_get_resource_types_resp" @@ -2193,6 +2286,7 @@ paths: $ref: "#/components/responses/scim_unauthorized" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20ResourceTypes /v2/scim/2.0/ResourceTypes/{name}: x-audience: public get: @@ -2202,14 +2296,15 @@ paths: tags: - scim parameters: - - name: name + - description: Name of the SCIM resource type. + name: name in: path schema: type: string example: User required: true security: - - bearerSCIMAuth: [] + - scimToken: [] responses: "200": $ref: "#/components/responses/scim_get_resource_type_resp" @@ -2219,6 +2314,7 @@ paths: $ref: "#/components/responses/scim_not_found" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20ResourceTypesByName /v2/scim/2.0/Schemas: x-audience: public get: @@ -2228,7 +2324,7 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] responses: "200": $ref: "#/components/responses/scim_get_schemas_resp" @@ -2236,6 +2332,7 @@ paths: $ref: "#/components/responses/scim_unauthorized" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20Schemas /v2/scim/2.0/Schemas/{id}: x-audience: public get: @@ -2245,14 +2342,15 @@ paths: tags: - scim parameters: - - name: id + - description: URN identifying the SCIM schema. + name: id in: path schema: type: string example: urn:ietf:params:scim:schemas:core:2.0:User required: true security: - - bearerSCIMAuth: [] + - scimToken: [] responses: "200": $ref: "#/components/responses/scim_get_schema_resp" @@ -2262,6 +2360,7 @@ paths: $ref: "#/components/responses/scim_not_found" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20SchemasById /v2/scim/2.0/Users: x-audience: public get: @@ -2292,14 +2391,14 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] parameters: - name: startIndex in: query schema: type: integer minimum: 1 - description: "" + description: "One-based index of the first result to return." example: 1 - name: count in: query @@ -2307,16 +2406,17 @@ paths: type: integer minimum: 1 maximum: 200 - description: "" + description: "Maximum number of results to return." example: 10 - name: filter in: query schema: type: string - description: "" + description: "SCIM filter expression used to select users." example: userName eq "jon.snow@docker.com" - $ref: "#/components/parameters/scim_attributes" - - name: sortOrder + - description: Sort direction applied to the attribute specified by sortBy. + name: sortOrder in: query schema: type: string @@ -2342,6 +2442,7 @@ paths: $ref: "#/components/responses/scim_not_found" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20Users post: summary: Create user description: | @@ -2349,7 +2450,7 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] requestBody: $ref: "#/components/requestBodies/scim_create_user_request" responses: @@ -2367,6 +2468,7 @@ paths: $ref: "#/components/responses/scim_conflict" "500": $ref: "#/components/responses/scim_error" + operationId: postV2Scim20Users /v2/scim/2.0/Users/{id}: x-audience: public parameters: @@ -2378,7 +2480,7 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] responses: "200": $ref: "#/components/responses/scim_get_user_resp" @@ -2392,6 +2494,7 @@ paths: $ref: "#/components/responses/scim_not_found" "500": $ref: "#/components/responses/scim_error" + operationId: getV2Scim20UsersById put: summary: Update a user description: | @@ -2399,7 +2502,7 @@ paths: tags: - scim security: - - bearerSCIMAuth: [] + - scimToken: [] requestBody: $ref: "#/components/requestBodies/scim_update_user_request" responses: @@ -2417,6 +2520,7 @@ paths: $ref: "#/components/responses/scim_conflict" "500": $ref: "#/components/responses/scim_error" + operationId: putV2Scim20UsersById components: responses: BadRequest: @@ -2424,25 +2528,49 @@ components: content: application/json: schema: - $ref: "#/components/schemas/ValueError" + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: bad request + errinfo: null Unauthorized: description: Unauthorized content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: unauthorized + errinfo: null Forbidden: description: Forbidden content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: permission denied + errinfo: null NotFound: description: Not Found content: application/json: schema: - $ref: "#/components/schemas/Error" + $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: not found + errinfo: null list_tags: description: list repository tags content: @@ -2461,40 +2589,83 @@ components: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: bad request + errinfo: null unauthorized: description: Unauthorized content: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: unauthorized + errinfo: null forbidden: description: Forbidden content: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: permission denied + errinfo: null not_found: description: Not Found content: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: not found + errinfo: null conflict: description: Conflict content: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: conflict + errinfo: null internal_error: description: Internal content: application/json: schema: $ref: "#/components/schemas/error" + examples: + error: + summary: Example error response + value: + message: internal error + errinfo: null scim_bad_request: description: Bad Request content: application/scim+json: + examples: + error: + summary: Example error envelope + value: + schemas: + - urn:ietf:params:scim:api:messages:2.0:Error + status: "400" schema: allOf: - $ref: "#/components/schemas/scim_error" @@ -2508,6 +2679,13 @@ components: description: Unauthorized content: application/scim+json: + examples: + error: + summary: Example error envelope + value: + schemas: + - urn:ietf:params:scim:api:messages:2.0:Error + status: "401" schema: allOf: - $ref: "#/components/schemas/scim_error" @@ -2518,6 +2696,13 @@ components: description: Forbidden content: application/scim+json: + examples: + error: + summary: Example error envelope + value: + schemas: + - urn:ietf:params:scim:api:messages:2.0:Error + status: "403" schema: allOf: - $ref: "#/components/schemas/scim_error" @@ -2528,6 +2713,13 @@ components: description: Not Found content: application/scim+json: + examples: + error: + summary: Example error envelope + value: + schemas: + - urn:ietf:params:scim:api:messages:2.0:Error + status: "404" schema: allOf: - $ref: "#/components/schemas/scim_error" @@ -2538,6 +2730,13 @@ components: description: Conflict content: application/scim+json: + examples: + error: + summary: Example error envelope + value: + schemas: + - urn:ietf:params:scim:api:messages:2.0:Error + status: "409" schema: allOf: - $ref: "#/components/schemas/scim_error" @@ -2548,6 +2747,13 @@ components: description: Internal Error content: application/scim+json: + examples: + error: + summary: Example error envelope + value: + schemas: + - urn:ietf:params:scim:api:messages:2.0:Error + status: "500" schema: allOf: - $ref: "#/components/schemas/scim_error" @@ -2575,10 +2781,22 @@ components: totalResults: type: integer example: 1 - resources: + Resources: type: array items: $ref: "#/components/schemas/scim_resource_type" + examples: + - schemas: + - urn:ietf:params:scim:api:messages:2.0:ListResponse + totalResults: 1 + Resources: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:ResourceType + id: User + name: User + description: User + endpoint: /Users + schema: urn:ietf:params:scim:schemas:core:2.0:User scim_get_resource_type_resp: description: "" content: @@ -2600,10 +2818,21 @@ components: totalResults: type: integer example: 1 - resources: + Resources: type: array items: $ref: "#/components/schemas/scim_schema" + examples: + - schemas: + - urn:ietf:params:scim:api:messages:2.0:ListResponse + totalResults: 1 + Resources: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:Schema + id: urn:ietf:params:scim:schemas:core:2.0:User + name: User + description: User Account + attributes: [] scim_get_schema_resp: description: "" content: @@ -2632,10 +2861,38 @@ components: itemsPerPage: type: integer example: 10 - resources: + Resources: type: array items: $ref: "#/components/schemas/scim_user" + examples: + - schemas: + - urn:ietf:params:scim:api:messages:2.0:ListResponse + totalResults: 1 + Resources: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:User + id: d80f7c79-7730-49d8-9a41-7c42fb622d9c + userName: jon.snow@docker.com + name: + givenName: Jon + familyName: Snow + displayName: jonsnow + active: true + emails: + - value: jon.snow@docker.com + display: jon.snow@docker.com + primary: true + groups: + - value: nightswatch + display: nightswatch + meta: + resourceType: User + location: https://hub.docker.com/v2/scim/2.0/Users/d80f7c79-7730-49d8-9a41-7c42fb622d9c + created: "2022-05-20T00:54:18Z" + lastModified: "2022-05-20T00:54:18Z" + startIndex: 1 + itemsPerPage: 1 scim_create_user_resp: description: "" content: @@ -2679,11 +2936,16 @@ components: type: string description: List of immutable tag rules example: - - "v.*" - - ".*-RELEASE" + - v.* + - .*-RELEASE required: - immutable_tags - immutable_tags_rules + examples: + - immutable_tags: true + immutable_tags_rules: + - v.* + - .*-RELEASE repo_creation_request: type: object required: @@ -2698,26 +2960,26 @@ components: Letters must be lowercase. minLength: 2 maxLength: 255 - pattern: "^[a-z0-9]+(?:[._-][a-z0-9]+)*$" - example: "my-app" + pattern: ^[a-z0-9]+(?:[._-][a-z0-9]+)*$ + example: my-app namespace: type: string description: The namespace where the repository will be created - example: "myorganization" + example: myorganization description: type: string description: Short description of the repository maxLength: 100 - example: "A sample application repository" + example: A sample application repository full_description: type: string description: Detailed description of the repository maxLength: 25000 - example: "This is a comprehensive description of my application repository that contains additional details about the project, its purpose, usage instructions, and other relevant information." + example: This is a comprehensive description of my application repository that contains additional details about the project, its purpose, usage instructions, and other relevant information. registry: type: string description: The registry where the repository will be hosted - example: "docker.io" + example: docker.io is_private: type: boolean description: Whether the repository should be private @@ -2741,20 +3003,26 @@ components: - read: Can view and pull from the repository - write: Can view, pull, and push to the repository - admin: Can view, pull, push, and manage repository settings - enum: ["read", "write", "admin"] - example: "write" + enum: + - read + - write + - admin + example: write RepositoryGroup: type: object properties: group_name: type: string description: The name of the group - example: "developers" + example: developers permission: type: string description: The permission level granted to the group - enum: ["read", "write", "admin"] - example: "write" + enum: + - read + - write + - admin + example: write group_id: type: integer format: int64 @@ -2773,8 +3041,9 @@ components: type: string description: Repository namespace repository_type: - type: string - nullable: true + type: + - string + - "null" description: Type of the repository status: type: integer @@ -2805,10 +3074,11 @@ components: example: "2021-01-05T21:06:53.506400Z" description: ISO 8601 timestamp of when repository was last updated last_modified: - type: string + type: + - string + - "null" format: date-time example: "2021-01-05T21:06:53.506400Z" - nullable: true description: ISO 8601 timestamp of when repository was last modified date_registered: type: string @@ -2820,27 +3090,31 @@ components: format: int64 description: Number of collaborators affiliation: - type: string - nullable: true + type: + - string + - "null" description: Repository affiliation hub_user: - type: string - nullable: true + type: + - string + - "null" description: Hub user information has_starred: type: boolean description: Whether the current user has starred this repository full_description: - type: string - nullable: true + type: + - string + - "null" description: Full description of the repository permissions: $ref: "#/components/schemas/repo_permissions" media_types: type: array items: - type: string - nullable: true + type: + - string + - "null" description: Supported media types content_types: type: array @@ -2855,13 +3129,15 @@ components: immutable_tags_settings: $ref: "#/components/schemas/immutable_tags_settings" storage_size: - type: integer + type: + - integer + - "null" format: int64 - nullable: true description: Storage size in bytes source: - type: string - nullable: true + type: + - string + - "null" description: Source of the repository, where it was created from required: - user @@ -2883,6 +3159,36 @@ components: - content_types - categories - immutable_tags_settings + examples: + - name: my-app + namespace: myorganization + repository_type: image + status: 1 + status_description: Active + description: A sample application repository + is_private: false + is_automated: false + star_count: 0 + pull_count: 0 + last_updated: "2025-01-20T10:30:00Z" + date_registered: "2025-01-20T10:30:00Z" + collaborator_count: 0 + hub_user: myorganization + has_starred: false + full_description: This is a comprehensive description of my application repository that contains additional details about the project. + media_types: [] + content_types: [] + categories: [] + immutable_tags_settings: + enabled: false + rules: [] + storage_size: null + source: null + user: myorganization + permissions: + read: true + write: true + admin: true repo_permissions: type: object properties: @@ -2918,12 +3224,19 @@ components: properties: regex: type: string - pattern: '^[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*(\\/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*$' - description: 'Immutable tags rule regex pattern. Must match format: [a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*(\\/[a-z0-9]+((\\.|_|__|-+)[a-z0-9]+)*)*' - example: "v.*" + pattern: ^[^,]*$ + description: Regular expression using Go regular expression syntax. Commas are not permitted. + example: v.* required: - regex + examples: + - regex: v.* immutable_tags_verify_response: + examples: + - tags: + - v1.0.0 + - v2.1.3 + - latest type: object properties: tags: @@ -2932,9 +3245,9 @@ components: type: string description: List of tags that match the provided regex pattern example: - - "v1.0.0" - - "v2.1.3" - - "latest" + - v1.0.0 + - v2.1.3 + - latest required: - tags repository_list_entry: @@ -2943,20 +3256,21 @@ components: name: type: string description: Name of the repository - example: "hello-world" + example: hello-world namespace: type: string description: Namespace (organization or username) that owns the repository - example: "docker" + example: docker repository_type: - type: string + type: + - string + - "null" description: Type of repository enum: - image - plugin - null - example: "image" - nullable: true + example: image status: type: integer description: Repository status code @@ -2967,12 +3281,13 @@ components: enum: - active - inactive - example: "active" + example: active description: - type: string + type: + - string + - "null" description: Repository description - nullable: true - example: "Hello World! (an example of minimal Dockerization)" + example: Hello World! (an example of minimal Dockerization) is_private: type: boolean description: Whether the repository is private @@ -2988,22 +3303,24 @@ components: minimum: 0 example: 50000000 last_updated: - type: string + type: + - string + - "null" format: date-time description: ISO 8601 timestamp of when the repository was last updated - example: "2023-12-01T10:30:00Z" - nullable: true + example: 2023-12-01T10:30:00Z last_modified: - type: string + type: + - string + - "null" format: date-time description: ISO 8601 timestamp of when the repository was last modified - example: "2023-12-01T10:30:00Z" - nullable: true + example: 2023-12-01T10:30:00Z date_registered: type: string format: date-time description: ISO 8601 timestamp of when the repository was created - example: "2013-06-19T19:07:54Z" + example: 2013-06-19T19:07:54Z affiliation: type: string description: User's affiliation with the repository (empty string if no affiliation) @@ -3014,14 +3331,14 @@ components: items: type: string example: - - "application/vnd.docker.plugin.v1+json" + - application/vnd.docker.plugin.v1+json content_types: type: array description: Content types supported by this repository items: type: string example: - - "plugin" + - plugin categories: type: array description: Categories associated with this repository @@ -3042,14 +3359,14 @@ components: name: type: string description: Human-readable name of the category - example: "Databases" + example: Databases minLength: 1 slug: type: string description: URL-friendly identifier for the category - example: "databases" + example: databases minLength: 1 - pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" + pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ description: Repository category for classification and discovery list_repositories_response: allOf: @@ -3061,6 +3378,9 @@ components: items: $ref: "#/components/schemas/repository_list_entry" UsersLoginRequest: + examples: + - username: myusername + password: p@ssw0rd description: User login details type: object required: @@ -3077,6 +3397,8 @@ components: type: string example: p@ssw0rd AuthCreateTokenResponse: + examples: + - access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c description: successful access token response type: object properties: @@ -3085,6 +3407,8 @@ components: type: string example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c PostUsersLoginSuccessResponse: + examples: + - token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c description: successful user login response type: object properties: @@ -3094,8 +3418,9 @@ components: This token can be used in the HTTP Authorization header as a JWT to authenticate with the Docker Hub APIs. type: string example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c - nullable: false PostUsersLoginErrorResponse: + examples: + - detail: Incorrect authentication credentials description: failed user login response or second factor required type: object required: @@ -3105,14 +3430,17 @@ components: description: Description of the error. type: string example: Incorrect authentication credentials - nullable: false login_2fa_token: description: | Short time lived token to be used on `/v2/users/2fa-login` to complete the authentication. This field is present only if 2FA is enabled. - type: string + type: + - string + - "null" example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c - nullable: true Users2FALoginRequest: + examples: + - login_2fa_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c + code: "123456" description: Second factor user login details type: object required: @@ -3127,8 +3455,10 @@ components: description: | The Time-based One-Time Password of the Docker Hub account to authenticate with. type: string - example: 123456 + example: "123456" PostUsers2FALoginErrorResponse: + examples: + - detail: Incorrect authentication credentials description: failed second factor login response. type: object properties: @@ -3136,27 +3466,6 @@ components: description: Description of the error. type: string example: Incorrect authentication credentials - nullable: false - protobufAny: - type: object - properties: - type_url: - type: string - value: - type: string - format: byte - rpcStatus: - type: object - properties: - code: - type: integer - format: int32 - message: - type: string - details: - type: array - items: - $ref: "#/components/schemas/protobufAny" AuditLogAction: type: object properties: @@ -3220,23 +3529,6 @@ components: action_description: type: string description: Audit log event. - ValueError: - type: object - description: Used to error if input validation fails. - properties: - fields: - type: object - items: - type: string - text: - type: string - Error: - type: object - properties: - detail: - type: string - message: - type: string accessToken: type: object properties: @@ -3256,9 +3548,10 @@ components: type: string example: "2021-07-20T12:00:00.000000Z" last_used: - type: string + type: + - string + - "null" example: null - nullable: true generated_by: type: string example: manual @@ -3268,6 +3561,7 @@ components: token: type: string example: a7a5ef25-8889-43a0-8cc7-f2a94268e861 + description: Token value returned on creation. Later responses redact the value as ***. token_label: type: string example: My read only token @@ -3278,10 +3572,16 @@ components: items: type: string expires_at: - type: string + type: + - string + - "null" format: date-time example: "2021-10-28T18:30:19.520861Z" createAccessTokenRequest: + examples: + - token_label: My read only token + scopes: + - repo:read type: object required: - token_label @@ -3307,8 +3607,22 @@ components: Optional expiration date for the token. If omitted, the token will remain valid indefinitely. format: date-time - example: "2021-10-28T18:30:19.520861Z" + example: 2021-10-28T18:30:19.520861Z createAccessTokensResponse: + examples: + - uuid: b30bbf97-506c-4ecd-aabc-842f3cb484fb + client_id: HUB + creator_ip: 127.0.0.1 + creator_ua: some user agent + created_at: "2021-07-20T12:00:00.000000Z" + last_used: null + generated_by: manual + is_active: true + token: a7a5ef25-8889-43a0-8cc7-f2a94268e861 + token_label: My read only token + scopes: + - repo:read + expires_at: "2021-10-28T18:30:19.520861Z" $ref: "#/components/schemas/accessToken" getAccessTokensResponse: type: object @@ -3317,10 +3631,14 @@ components: type: number example: 1 next: - type: string + type: + - string + - "null" example: null previous: - type: string + type: + - string + - "null" example: null active_count: type: number @@ -3334,8 +3652,30 @@ components: properties: token: type: string - example: "" + example: "***" + examples: + - count: 1 + active_count: 1 + next: null + previous: null + results: + - uuid: b30bbf97-506c-4ecd-aabc-842f3cb484fb + client_id: HUB + creator_ip: 127.0.0.1 + creator_ua: some user agent + created_at: "2021-07-20T12:00:00.000000Z" + last_used: null + generated_by: manual + is_active: true + token: "***" + token_label: My read only token + scopes: + - repo:read + expires_at: "2021-10-28T18:30:19.520861Z" patchAccessTokenRequest: + examples: + - token_label: My read only token + is_active: false type: object properties: token_label: @@ -3348,7 +3688,26 @@ components: example: false patchAccessTokenResponse: $ref: "#/components/schemas/accessToken" + examples: + - uuid: b30bbf97-506c-4ecd-aabc-842f3cb484fb + client_id: HUB + creator_ip: 127.0.0.1 + creator_ua: some user agent + created_at: "2021-07-20T12:00:00.000000Z" + last_used: null + generated_by: manual + is_active: true + token: "***" + token_label: My read only token + scopes: + - repo:read + expires_at: "2021-10-28T18:30:19.520861Z" orgSettings: + examples: + - restricted_images: + enabled: true + allow_official_images: true + allow_verified_publishers: true type: object properties: restricted_images: @@ -3372,9 +3731,10 @@ components: type: object properties: digest: - type: string + type: + - string + - "null" description: image layer digest - nullable: true size: type: integer description: size of the layer @@ -3388,15 +3748,20 @@ components: type: string description: CPU architecture features: - type: string + type: + - string + - "null" description: CPU features variant: - type: string + type: + - string + - "null" description: CPU variant digest: - type: string + type: + - string + - "null" description: image digest - nullable: true layers: type: array items: @@ -3405,10 +3770,14 @@ components: type: string description: operating system os_features: - type: string + type: + - string + - "null" description: OS features os_version: - type: string + type: + - string + - "null" description: OS version size: type: integer @@ -3420,15 +3789,17 @@ components: - inactive description: Status of the image last_pulled: - type: string + type: + - string + - "null" example: "2021-01-05T21:06:53.506400Z" description: datetime of last pull - nullable: true last_pushed: - type: string + type: + - string + - "null" example: "2021-01-05T21:06:53.506400Z" description: datetime of last push - nullable: true tag: type: object properties: @@ -3436,16 +3807,20 @@ components: type: integer description: tag ID images: - type: object - $ref: "#/components/schemas/image" + type: + - array + - "null" + items: + $ref: "#/components/schemas/image" creator: type: integer description: ID of the user that pushed the tag last_updated: - type: string + type: + - string + - "null" example: "2021-01-05T21:06:53.506400Z" description: datetime of last update - nullable: true last_updater: type: integer description: ID of the last user that updated the tag @@ -3462,24 +3837,50 @@ components: type: integer description: compressed size (sum of all layers) of the tagged image v2: - type: string - description: repository API version - status: - type: string - enum: - - active - - inactive - description: whether a tag has been pushed to or pulled in the past month + type: boolean + description: Whether the tag uses the Registry V2 format. tag_last_pulled: - type: string + type: + - string + - "null" example: "2021-01-05T21:06:53.506400Z" description: datetime of last pull - nullable: true tag_last_pushed: - type: string + type: + - string + - "null" example: "2021-01-05T21:06:53.506400Z" description: datetime of last push - nullable: true + tag_status: + type: string + enum: + - active + - inactive + description: whether a tag has been pushed to or pulled in the past month + examples: + - id: 12345 + name: latest + last_updated: "2021-01-05T21:06:53.506400Z" + full_size: 123456 + v2: true + images: + - architecture: amd64 + features: null + variant: null + os: linux + os_features: null + os_version: null + size: 123456 + status: active + last_pulled: null + last_pushed: "2021-01-05T21:06:53.506400Z" + creator: 1234 + last_updater: 1234 + last_updater_username: dockeruser + repository: 5678 + tag_status: active + tag_last_pulled: null + tag_last_pushed: "2021-01-05T21:06:53.506400Z" paginated_tags: allOf: - $ref: "#/components/schemas/page" @@ -3489,6 +3890,34 @@ components: type: array items: $ref: "#/components/schemas/tag" + examples: + - count: 1 + next: null + previous: null + results: + - id: 12345 + name: latest + last_updated: "2021-01-05T21:06:53.506400Z" + full_size: 123456 + v2: true + images: + - architecture: amd64 + features: null + variant: null + os: linux + os_features: null + os_version: null + size: 123456 + status: active + last_pulled: null + last_pushed: "2021-01-05T21:06:53.506400Z" + creator: 1234 + last_updater: 1234 + last_updater_username: dockeruser + repository: 5678 + tag_status: active + tag_last_pulled: null + tag_last_pushed: "2021-01-05T21:06:53.506400Z" page: type: object properties: @@ -3496,13 +3925,15 @@ components: type: integer description: total number of results available across all pages next: - type: string + type: + - string + - "null" description: link to next page of results if any - nullable: true previous: - type: string + type: + - string + - "null" description: link to previous page of results if any - nullable: true scim_schema_attribute: type: object properties: @@ -3570,7 +4001,7 @@ components: example: owners created_at: type: string - example: "2021-10-28T18:30:19.520861Z" + example: 2021-10-28T18:30:19.520861Z bulk_invite: type: object properties: @@ -3599,7 +4030,7 @@ components: invitee: invitee@docker.com org: docker team: owners - created_at: "2021-10-28T18:30:19.520861Z" + created_at: 2021-10-28T18:30:19.520861Z - invitee: invitee2@docker.com status: existing_org_member - invitee: invitee3@docker.com @@ -3608,13 +4039,16 @@ components: type: object properties: errinfo: - type: object - items: - type: string + type: + - object + - "null" + additionalProperties: true + description: Additional error context, when available. detail: type: string message: type: string + description: Error details vary by endpoint. The message describes the failure; errinfo can contain additional context. scim_error: type: object properties: @@ -3641,7 +4075,7 @@ components: example: Docker Inc date_joined: type: string - example: "2021-01-05T21:06:53.506400Z" + example: 2021-01-05T21:06:53.506400Z full_name: type: string example: Jon Snow @@ -3699,26 +4133,40 @@ components: example: example@docker.com deprecated: true last_logged_in_at: - type: string + type: + - string + - "null" format: date-time description: | - Last time the user logged in. To access this field, you must have insights visible for your organization. See + Last time the user logged in. To access this field, you must have insights visible for your organization. See [Insights](https://docs.docker.com/admin/insights/#view-insights-for-organization-users). example: "2021-01-05T21:06:53.506400Z" last_seen_at: - type: string + type: + - string + - "null" format: date-time description: | - Last time the user was seen. To access this field, you must have insights visible for your organization. See + Last time the user was seen. To access this field, you must have insights visible for your organization. See [Insights](https://docs.docker.com/admin/insights/#view-insights-for-organization-users). example: "2021-01-05T21:06:53.506400Z" last_desktop_version: type: string description: | - Last desktop version the user used. To access this field, you must have insights visible for your organization. See + Last desktop version the user used. To access this field, you must have insights visible for your organization. See [Insights](https://docs.docker.com/admin/insights/#view-insights-for-organization-users). example: 4.29.0 - + examples: + - id: 0ab70deb065a43fcacd55d48caa945d8 + username: dockeruser + full_name: Jon Snow + type: User + date_joined: "2021-01-05T21:06:53.506400Z" + email: example@docker.com + role: Owner + groups: + - owners + is_guest: false org_member_paginated: type: object properties: @@ -3727,18 +4175,37 @@ components: description: The total number of items that match with the search. example: 120 previous: - type: string + type: + - string + - "null" description: The URL or link for the previous page of items. - example: https://hub.docker.com/v2/some/resources/items?page=1&page_size=20 + example: null next: - type: string + type: + - string + - "null" description: The URL or link for the next page of items. - example: https://hub.docker.com/v2/some/resources/items?page=3&page_size=20 + example: null results: type: array description: List of accounts. items: $ref: "#/components/schemas/org_member" + examples: + - count: 1 + next: null + previous: null + results: + - id: 0ab70deb065a43fcacd55d48caa945d8 + username: dockeruser + full_name: Jon Snow + type: User + date_joined: "2021-01-05T21:06:53.506400Z" + email: example@docker.com + role: Owner + groups: + - owners + is_guest: false org_group: type: object properties: @@ -3762,10 +4229,19 @@ components: example: 10 description: Member count of the group role: - type: string + type: + - string + - "null" description: | Role assigned to the team. A core role (`owner`, `editor`, or `member`) or the name of a custom role (not the label or UUID). + examples: + - id: 10 + uuid: e36eca69-4cc8-4f17-9845-ae8c2b832691 + name: developers + description: Development team + member_count: 1 + role: member group_member: type: object properties: @@ -3779,7 +4255,7 @@ components: date_joined: type: string format: date-time - example: "2021-01-05T21:06:53.506400Z" + example: 2021-01-05T21:06:53.506400Z full_name: type: string example: John Snow @@ -3849,7 +4325,8 @@ components: properties: supported: type: boolean - example: false + example: true + type: object bulk: type: object properties: @@ -3868,7 +4345,7 @@ components: example: true maxResults: type: integer - example: 99999 + example: 200 changePassword: type: object properties: @@ -3888,21 +4365,53 @@ components: type: boolean example: false authenticationSchemes: - type: object - properties: - name: - type: string - example: OAuth 2.0 Bearer Token - description: - type: string - example: The OAuth 2.0 Bearer Token Authentication scheme. OAuth enables clients to access protected resources by obtaining an access token, which is defined in RFC 6750 as "a string representing an access authorization issued to the client", rather than using the resource owner's credentials directly. - specUri: - type: string - example: http://tools.ietf.org/html/rfc6750 - type: - type: string - example: oauthbearertoken + type: array + items: + type: object + properties: + name: + type: string + example: OAuth 2.0 Bearer Token + description: + type: string + example: The OAuth 2.0 Bearer Token Authentication scheme. OAuth enables clients to access protected resources by obtaining an access token, which is defined in RFC 6750 as "a string representing an access authorization issued to the client", rather than using the resource owner's credentials directly. + specUri: + type: string + example: http://tools.ietf.org/html/rfc6750 + type: + type: string + example: oauthbearertoken + examples: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig + patch: + supported: true + bulk: + supported: false + maxOperations: 0 + maxPayloadSize: 0 + filter: + supported: true + maxResults: 200 + changePassword: + supported: false + sort: + supported: true + etag: + supported: false + authenticationSchemes: + - name: OAuth 2.0 Bearer Token + specUri: http://tools.ietf.org/html/rfc6750 + type: oauthbearertoken scim_resource_type: + examples: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:ResourceType + id: User + name: User + description: User + endpoint: /Users + schema: urn:ietf:params:scim:schemas:core:2.0:User type: object properties: schemas: @@ -3927,6 +4436,13 @@ components: type: string example: urn:ietf:params:scim:schemas:core:2.0:User scim_schema: + examples: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:Schema + id: urn:ietf:params:scim:schemas:core:2.0:User + name: User + description: User Account + attributes: [] type: object properties: schemas: @@ -3998,6 +4514,28 @@ components: example: d80f7c79-7730-49d8-9a41-7c42fb622d9c description: The unique identifier for the user. A v4 UUID. scim_user: + examples: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:User + id: d80f7c79-7730-49d8-9a41-7c42fb622d9c + userName: jon.snow@docker.com + name: + givenName: Jon + familyName: Snow + displayName: jonsnow + active: true + emails: + - value: jon.snow@docker.com + display: jon.snow@docker.com + primary: true + groups: + - value: nightswatch + display: nightswatch + meta: + resourceType: User + location: https://hub.docker.com/v2/scim/2.0/Users/d80f7c79-7730-49d8-9a41-7c42fb622d9c + created: "2022-05-20T00:54:18Z" + lastModified: "2022-05-20T00:54:18Z" type: object properties: schemas: @@ -4034,41 +4572,43 @@ components: type: string format: date-time description: The creation date for the user as a RFC3339 formatted string. - example: "2022-05-20T00:54:18Z" + example: 2022-05-20T00:54:18Z lastModified: type: string format: date-time description: The date the user was last modified as a RFC3339 formatted string. - example: "2022-05-20T00:54:18Z" + example: 2022-05-20T00:54:18Z orgAccessToken: type: object properties: id: type: string - example: "a7a5ef25-8889-43a0-8cc7-f2a94268e861" + example: a7a5ef25-8889-43a0-8cc7-f2a94268e861 label: type: string - example: "My organization token" + example: My organization token created_by: type: string - example: "johndoe" + example: johndoe is_active: type: boolean example: true created_at: type: string format: date-time - example: "2022-05-20T00:54:18Z" + example: 2022-05-20T00:54:18Z expires_at: - type: string + type: + - string + - "null" format: date-time - example: "2023-05-20T00:54:18Z" - nullable: true + example: 2023-05-20T00:54:18Z last_used_at: - type: string + type: + - string + - "null" format: date-time - example: "2022-06-15T12:30:45Z" - nullable: true + example: 2022-06-15T12:30:45Z orgAccessTokenResource: type: object properties: @@ -4077,12 +4617,11 @@ components: enum: - TYPE_REPO - TYPE_ORG - example: "TYPE_REPO" + example: TYPE_REPO description: The type of resource - required: true path: type: string - example: "myorg/myrepo" + example: myorg/myrepo description: | The path of the resource. The format of this will change depending on the type of resource. @@ -4090,14 +4629,16 @@ components: - Must be an existing repository name (e.g., "myorg/myrepo") - Can use glob patterns (e.g., "myorg/*" for all repositories in the organization) - Use "*/*/public" to reference all public repositories - required: true scopes: type: array description: The scopes this token has access to items: type: string - example: "scope-image-pull" - required: true + example: scope-image-pull + required: + - type + - path + - scopes getOrgAccessTokensResponse: type: object properties: @@ -4105,16 +4646,45 @@ components: type: number example: 10 next: - type: string - example: https://hub.docker.com/v2/orgs/docker/access-tokens?page=2&page_size=10 + type: + - string + - "null" + example: null previous: - type: string - example: https://hub.docker.com/v2/orgs/docker/access-tokens?page=1&page_size=10 + type: + - string + - "null" + example: null results: type: array items: $ref: "#/components/schemas/orgAccessToken" + examples: + - total: 1 + next: null + previous: null + results: + - id: a7a5ef25-8889-43a0-8cc7-f2a94268e861 + label: My organization token + created_by: johndoe + is_active: true + created_at: "2022-05-20T00:54:18Z" + expires_at: "2023-05-20T00:54:18Z" + last_used_at: "2022-06-15T12:30:45Z" getOrgAccessTokenResponse: + examples: + - id: a7a5ef25-8889-43a0-8cc7-f2a94268e861 + label: My organization token + created_by: johndoe + is_active: true + created_at: "2022-05-20T00:54:18Z" + expires_at: "2023-05-20T00:54:18Z" + last_used_at: "2022-06-15T12:30:45Z" + resources: + - type: TYPE_REPO + path: myorg/myrepo + scopes: + - scope-image-pull allOf: - $ref: "#/components/schemas/orgAccessToken" - type: object @@ -4125,77 +4695,111 @@ components: items: $ref: "#/components/schemas/orgAccessTokenResource" createOrgAccessTokenRequest: + examples: + - label: My organization token + description: Token for CI/CD pipeline + resources: + - type: TYPE_REPO + path: myorg/myrepo + scopes: + - scope-image-pull type: object properties: label: type: string description: Label for the access token - example: "My organization token" - required: true + example: My organization token description: type: string description: Description of the access token - example: "Token for CI/CD pipeline" + example: Token for CI/CD pipeline resources: type: array description: Resources this token has access to items: $ref: "#/components/schemas/orgAccessTokenResource" expires_at: - type: string + type: + - string + - "null" format: date-time description: Expiration date for the token - example: "2023-05-20T00:54:18Z" - nullable: true + example: 2023-05-20T00:54:18Z + required: + - label createOrgAccessTokenResponse: + examples: + - id: a7a5ef25-8889-43a0-8cc7-f2a94268e861 + label: My organization token + is_active: true + created_at: "2022-05-20T00:54:18Z" + expires_at: "2023-05-20T00:54:18Z" + last_used_at: "2022-06-15T12:30:45Z" + token: dckr_oat_7awgM4jG5SQvxcvmNzhKj8PQjxo + resources: + - type: TYPE_REPO + path: myorg/myrepo + scopes: + - scope-image-pull type: object allOf: - type: object properties: id: type: string - example: "a7a5ef25-8889-43a0-8cc7-f2a94268e861" + example: a7a5ef25-8889-43a0-8cc7-f2a94268e861 label: type: string - example: "My organization token" + example: My organization token is_active: type: boolean example: true created_at: type: string format: date-time - example: "2022-05-20T00:54:18Z" + example: 2022-05-20T00:54:18Z expires_at: - type: string + type: + - string + - "null" format: date-time - example: "2023-05-20T00:54:18Z" - nullable: true + example: 2023-05-20T00:54:18Z last_used_at: - type: string + type: + - string + - "null" format: date-time - example: "2022-06-15T12:30:45Z" - nullable: true + example: 2022-06-15T12:30:45Z - type: object properties: token: type: string description: The actual token value that can be used for authentication - example: "dckr_oat_7awgM4jG5SQvxcvmNzhKj8PQjxo" + example: dckr_oat_7awgM4jG5SQvxcvmNzhKj8PQjxo resources: type: array items: $ref: "#/components/schemas/orgAccessTokenResource" updateOrgAccessTokenRequest: + examples: + - label: My organization token + description: Token for CI/CD pipeline + resources: + - type: TYPE_REPO + path: myorg/myrepo + scopes: + - scope-image-pull + is_active: true type: object properties: label: type: string description: Label for the access token - example: "My organization token" + example: My organization token description: type: string description: Description of the access token - example: "Token for CI/CD pipeline" + example: Token for CI/CD pipeline resources: type: array description: Resources this token has access to @@ -4206,6 +4810,19 @@ components: description: Whether the token is active example: true updateOrgAccessTokenResponse: + examples: + - id: a7a5ef25-8889-43a0-8cc7-f2a94268e861 + label: My organization token + created_by: johndoe + is_active: true + created_at: "2022-05-20T00:54:18Z" + expires_at: "2023-05-20T00:54:18Z" + last_used_at: "2022-06-15T12:30:45Z" + resources: + - type: TYPE_REPO + path: myorg/myrepo + scopes: + - scope-image-pull type: object allOf: - $ref: "#/components/schemas/orgAccessToken" @@ -4216,34 +4833,23 @@ components: description: Resources this token has access to items: $ref: "#/components/schemas/orgAccessTokenResource" - team_repo: - allOf: - - $ref: "#/components/responses/team_repo" - properties: - group_name: - type: string - description: Name of the group - permission: - type: string - description: Repo access permission - enum: - - read - - write - - admin parameters: namespace: + description: Namespace of the repository, such as a user or organization name. in: path name: namespace required: true schema: type: string repository: + description: Name of the repository within the namespace. in: path name: repository required: true schema: type: string tag: + description: Name of the image tag. in: path name: tag required: true @@ -4313,6 +4919,7 @@ components: example: d80f7c79-7730-49d8-9a41-7c42fb622d9c required: true type: + description: Filter members by membership type. in: query name: type schema: @@ -4323,6 +4930,7 @@ components: - member example: all role: + description: Filter members by their organization role. in: query name: role schema: @@ -4344,6 +4952,15 @@ components: content: application/json: schema: + examples: + - org: docker + team: owners + role: member + invitees: + - invitee1DockerId + - invitee2@docker.com + - invitee3@docker.com + dry_run: true type: object required: - org @@ -4380,6 +4997,13 @@ components: content: application/scim+json: schema: + examples: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:User + userName: jon.snow@docker.com + name: + givenName: Jon + familyName: Snow type: object required: - schemas @@ -4399,22 +5023,33 @@ components: type: object required: - schemas + - userName properties: schemas: $ref: "#/components/schemas/scim_user_schemas" name: - allOf: - - $ref: "#/components/schemas/scim_user_name" - - description: If this is omitted from the request, the update will skip the update on it. We will only ever change the name, but not clear it. - enabled: + $ref: "#/components/schemas/scim_user_name" + userName: + $ref: "#/components/schemas/scim_user_username" + active: type: boolean default: false - description: If this is omitted from the request, it will default to false resulting in a deactivated user. + description: Whether the user is active. Defaults to false when omitted. + examples: + - schemas: + - urn:ietf:params:scim:schemas:core:2.0:User + userName: jon.snow@docker.com + name: + givenName: Jon + familyName: Snow + active: true add_member_to_org_group: required: true content: application/json: schema: + examples: + - member: jonsnow type: object required: - member @@ -4442,6 +5077,10 @@ components: bearerSCIMAuth: type: http scheme: bearer + scimToken: + type: http + scheme: bearer + description: Use the SCIM provisioning token configured for the organization. x-tagGroups: - name: General tags: @@ -4462,3 +5101,5 @@ x-tagGroups: - org-access-tokens - groups - invites +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +security: [] diff --git a/content/reference/api/registry/latest.md b/content/reference/api/registry/latest.md deleted file mode 100644 index 7df6e5971659..000000000000 --- a/content/reference/api/registry/latest.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -layout: api -title: Supported registry API for Docker Hub -linktitle: Latest -description: "Supported registry API endpoints." -keywords: registry, on-prem, images, tags, repository, distribution, api, advanced ---- diff --git a/content/reference/api/registry/latest.yaml b/content/reference/api/registry/latest.yaml index 445cc7f98f95..e28bb4506f9c 100644 --- a/content/reference/api/registry/latest.yaml +++ b/content/reference/api/registry/latest.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.2.0 info: title: Supported registry API for Docker Hub description: | @@ -11,14 +11,13 @@ info: It focuses on pulling, pushing, and deleting images. It does not cover the full OCI Distribution Specification. For the complete OCI specification, see [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec). + version: "2" servers: - description: Docker Hub registry API x-audience: public url: https://registry-1.docker.io - tags: - name: overview - x-displayName: Overview description: | All endpoints in this API are prefixed by the version and repository name, for example: @@ -38,29 +37,28 @@ tags: 1. Consist of path components matching `[a-z0-9]+(?:[._-][a-z0-9]+)*` 2. If more than one component, they must be separated by `/` 3. Full repository name must be fewer than 256 characters - - + summary: Overview + kind: info - name: authentication - x-displayName: Authentication description: | Specifies registry authentication. externalDocs: description: Detailed authentication workflow and token usage url: https://docs.docker.com/reference/api/registry/auth/ - + summary: Authentication + kind: info - name: Manifests - x-displayName: Manifests description: | Image manifests are JSON documents that describe an image: its configuration blob, the digests of each layer blob, and metadata such as media‑types and annotations. - + summary: Manifests + kind: nav - name: Blobs - x-displayName: Blobs description: | Blobs are the binary objects referenced from manifests: the config JSON and one or more compressed layer tarballs. - + summary: Blobs + kind: nav - name: pull - x-displayName: Pulling Images description: | Pulling an image involves retrieving the manifest and downloading each of the image's layer blobs. This section outlines the general steps followed by a working example. @@ -79,94 +77,93 @@ tags: # Step 1: Get a bearer token TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/ubuntu:pull" | jq -r .token) - + # Step 2: Get the image manifest. In this example, an image manifest list is returned. curl -s -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \ https://registry-1.docker.io/v2/library/ubuntu/manifests/latest \ -o manifest-list.json - + # Step 3a: Parse the `manifests[]` array to locate the digest for your target platform (e.g., `linux/amd64`). IMAGE_MANIFEST_DIGEST=$(jq -r '.manifests[] | select(.platform.architecture == "amd64" and .platform.os == "linux") | .digest' manifest-list.json) - + # Step 3b: Get the platform-specific image manifest curl -s -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ https://registry-1.docker.io/v2/library/ubuntu/manifests/$IMAGE_MANIFEST_DIGEST \ -o manifest.json - + # Step 4: Send a HEAD request to check if the layer blob exists DIGEST=$(jq -r '.layers[0].digest' manifest.json) curl -I -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/library/ubuntu/blobs/$DIGEST - + # Step 5: Download the layer blob curl -L -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/library/ubuntu/blobs/$DIGEST ``` - - This example pulls the manifest and the first layer for the `ubuntu:latest` image on the `linux/amd64` platform. Repeat steps 4 and 5 for each digest in the `.layers[]` array in the manifest. - + This example pulls the manifest and the first layer for the `ubuntu:latest` image on the `linux/amd64` platform. Repeat steps 4 and 5 for each digest in the `.layers[]` array in the manifest. + summary: Pulling Images + kind: info - name: push - x-displayName: Pushing Images description: | Pushing an image involves uploading any image blobs (such as the config or layers), and then uploading the manifest that references those blobs. - + This section outlines the basic steps to push an image using the registry API. - + 1. [Get a bearer token for the repository](https://docs.docker.com/reference/api/registry/auth/) - + 2. [Check if the blob exists](#operation/CheckBlobExists) using a `HEAD` request for each blob digest. - + 3. If the blob does not exist, [upload the blob](#operation/CompleteBlobUpload) using a monolithic `PUT` request: - First, [initiate the upload](#operation/InitiateBlobUpload) with `POST`. - Then [upload and complete](#operation/CompleteBlobUpload) with `PUT`. **Note**: Alternatively, you can upload the blob in multiple chunks by using `PATCH` requests to send each chunk, followed by a final `PUT` request to complete the upload. This is known as a [chunked upload](#operation/UploadBlobChunk) and is useful for large blobs or when resuming interrupted uploads. - + 4. [Upload the image manifest](#operation/PutImageManifest) using a `PUT` request to associate the config and layers. - + The following bash script example pushes a dummy config blob and manifest to `yourusername/helloworld:latest` on Docker Hub. You can replace `yourusername` with your Docker Hub username and `dckr_pat` with your Docker Hub personal access token. - + ```bash #!/bin/bash - + USERNAME=yourusername PASSWORD=dckr_pat REPO=yourusername/helloworld TAG=latest CONFIG=config.json MIME_TYPE=application/vnd.docker.container.image.v1+json - + # Step 1: Get a bearer token TOKEN=$(curl -s -u "$USERNAME:$PASSWORD" \ "https://auth.docker.io/token?service=registry.docker.io&scope=repository:$REPO:push,pull" \ | jq -r .token) - + # Create a dummy config blob and compute its digest echo '{"architecture":"amd64","os":"linux","config":{},"rootfs":{"type":"layers","diff_ids":[]}}' > $CONFIG DIGEST="sha256:$(sha256sum $CONFIG | awk '{print $1}')" - + # Step 2: Check if the blob exists STATUS=$(curl -s -o /dev/null -w "%{http_code}" -I \ -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/$REPO/blobs/$DIGEST) - + if [ "$STATUS" != "200" ]; then # Step 3: Upload blob using monolithic upload LOCATION=$(curl -sI -X POST \ -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/$REPO/blobs/uploads/ \ | grep -i Location | tr -d '\r' | awk '{print $2}') - + curl -s -X PUT "$LOCATION&digest=$DIGEST" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary @$CONFIG fi - + # Step 4: Upload the manifest that references the config blob MANIFEST=$(cat <` header. - + + Use the `Accept` header to select the manifest representation. Supported media types: + + - `application/vnd.docker.distribution.manifest.v2+json` + - `application/vnd.docker.distribution.manifest.list.v2+json` + - `application/vnd.oci.image.manifest.v1+json` + - `application/vnd.oci.image.index.v1+json` x-codeSamples: - lang: Bash label: cURL @@ -289,26 +292,6 @@ paths: value: sha256:abc123def456... schema: type: string - - name: Authorization - in: header - required: true - description: RFC7235-compliant authorization header (e.g., `Bearer `). - schema: - type: string - - name: Accept - in: header - required: false - description: | - Media type(s) the client supports for the manifest. - - The registry supports the following media types: - - application/vnd.docker.distribution.manifest.v2+json - - application/vnd.docker.distribution.manifest.list.v2+json - - application/vnd.oci.image.manifest.v1+json - - application/vnd.oci.image.index.v1+json - schema: - type: string - responses: "200": description: Manifest fetched successfully. @@ -367,28 +350,19 @@ paths: docker-manifest: summary: Docker image manifest (schema v2) value: - { - "schemaVersion": 2, - "mediaType": "application/vnd.docker.distribution.manifest.v2+json", - "config": { - "mediaType": "application/vnd.docker.container.image.v1+json", - "size": 7023, - "digest": "sha256:123456abcdef..." - }, - "layers": [ - { - "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", - "size": 32654, - "digest": "sha256:abcdef123456..." - }, - { - "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", - "size": 16724, - "digest": "sha256:7890abcdef12..." - } - ] - } - + schemaVersion: 2 + mediaType: application/vnd.docker.distribution.manifest.v2+json + config: + mediaType: application/vnd.docker.container.image.v1+json + size: 7023 + digest: sha256:123456abcdef... + layers: + - mediaType: application/vnd.docker.image.rootfs.diff.tar.gzip + size: 32654 + digest: sha256:abcdef123456... + - mediaType: application/vnd.docker.image.rootfs.diff.tar.gzip + size: 16724 + digest: sha256:7890abcdef12... "400": description: Invalid name or reference. "401": @@ -399,8 +373,6 @@ paths: description: Repository or manifest not found. "429": description: Too many requests. - - put: tags: - Manifests @@ -446,20 +418,6 @@ paths: value: sha256:abc123def456... schema: type: string - - name: Authorization - in: header - required: true - description: RFC7235-compliant authorization header (e.g., `Bearer `). - schema: - type: string - - name: Content-Type - in: header - required: true - description: Media type of the manifest being uploaded. - schema: - type: string - example: application/vnd.docker.distribution.manifest.v2+json - requestBody: required: true content: @@ -512,28 +470,20 @@ paths: digest: type: string example: sha256:abcdef123456... - examples: sample-manifest: summary: Sample Docker image manifest (schema v2) value: - { - "schemaVersion": 2, - "mediaType": "application/vnd.docker.distribution.manifest.v2+json", - "config": { - "mediaType": "application/vnd.docker.container.image.v1+json", - "size": 7023, - "digest": "sha256:123456abcdef..." - }, - "layers": [ - { - "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", - "size": 32654, - "digest": "sha256:abcdef123456..." - } - ] - } - + schemaVersion: 2 + mediaType: application/vnd.docker.distribution.manifest.v2+json + config: + mediaType: application/vnd.docker.container.image.v1+json + size: 7023 + digest: sha256:123456abcdef... + layers: + - mediaType: application/vnd.docker.image.rootfs.diff.tar.gzip + size: 32654 + digest: sha256:abcdef123456... responses: "201": description: Manifest created successfully. @@ -579,6 +529,12 @@ paths: This endpoint requires authentication with pull scope. + Use the `Accept` header to select the manifest representation. Supported media types: + + - `application/vnd.docker.distribution.manifest.v2+json` + - `application/vnd.docker.distribution.manifest.list.v2+json` + - `application/vnd.oci.image.manifest.v1+json` + - `application/vnd.oci.image.index.v1+json` parameters: - name: name in: path @@ -600,20 +556,6 @@ paths: value: sha256:abc123def456... schema: type: string - - name: Authorization - in: header - required: true - schema: - type: string - description: Bearer token for authentication - - name: Accept - in: header - required: false - schema: - type: string - example: application/vnd.docker.distribution.manifest.v2+json - description: | - Media type of the manifest to check. The response will match one of the accepted types. x-codeSamples: - lang: Bash label: cURL @@ -642,12 +584,12 @@ paths: schema: type: string example: application/vnd.docker.distribution.manifest.v2+json - "404": - description: Manifest not found. "401": description: Authentication required. "403": description: Access denied. + "404": + description: Manifest not found. "429": description: Too many requests. delete: @@ -680,12 +622,6 @@ paths: example: sha256:abc123def456... schema: type: string - - name: Authorization - in: header - required: true - description: Bearer token with `delete` access - schema: - type: string x-codeSamples: - lang: Bash label: cURL @@ -733,7 +669,6 @@ paths: curl -i -X POST \ -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/library/ubuntu/blobs/uploads/ - - lang: Bash label: cURL (Cross-Repository Blob Mount) source: | @@ -741,7 +676,6 @@ paths: curl -i -X POST \ -H "Authorization: Bearer $TOKEN" \ "https://registry-1.docker.io/v2/library/ubuntu/blobs/uploads/?mount=sha256:abc123def456...&from=library/busybox" - parameters: - name: name in: path @@ -764,13 +698,6 @@ paths: schema: type: string example: library/busybox - - name: Authorization - in: header - required: true - schema: - type: string - description: Bearer token for authentication with `push` scope - responses: "201": description: Blob successfully mounted from another repository. @@ -858,14 +785,6 @@ paths: schema: type: string example: sha256:abc123def4567890... - - name: Authorization - in: header - required: true - description: Bearer token with pull or push scope - schema: - type: string - example: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6... - responses: "200": description: Blob exists @@ -885,32 +804,12 @@ paths: schema: type: string example: application/octet-stream - content: - application/json: - examples: - blob-check-request: - summary: Sample request - value: - method: HEAD - url: /v2/library/ubuntu/blobs/sha256:abc123def4567890... - headers: - Authorization: Bearer - Accept: '*/*' - blob-check-response: - summary: Sample 200 response headers - value: - status: 200 OK - headers: - Docker-Content-Digest: sha256:abc123def4567890... - Content-Length: 32654 - Content-Type: application/octet-stream - - "404": - description: Blob not found "401": description: Authentication required "403": description: Access denied + "404": + description: Blob not found "429": description: Too many requests get: @@ -950,14 +849,6 @@ paths: schema: type: string example: sha256:abc123def456... - - name: Authorization - in: header - required: true - schema: - type: string - description: Bearer token with pull scope - example: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6... - responses: "200": description: Blob content returned directly @@ -985,8 +876,7 @@ paths: examples: small-layer: summary: Example binary blob (gzipped tar layer) - value: "" - + value: "307": description: Temporary redirect to blob location headers: @@ -1023,7 +913,7 @@ paths: label: cURL source: | # GET upload status - curl -I \ + curl --include --request GET \ -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/library/ubuntu/blobs/uploads/abc123 parameters: @@ -1031,7 +921,7 @@ paths: in: path required: true description: Repository Name - example : library/ubuntu + example: library/ubuntu schema: type: string - name: uuid @@ -1041,13 +931,6 @@ paths: schema: type: string example: abc123 - - name: Authorization - in: header - required: true - schema: - type: string - example: Bearer eyJhbGciOi... - responses: "204": description: Upload in progress. No body is returned. @@ -1075,7 +958,6 @@ paths: description: Upload session not found "429": description: Too many requests - put: tags: - Blobs @@ -1089,7 +971,6 @@ paths: This endpoint supports: - Monolithic uploads (upload entire blob in this request) - Finalizing chunked uploads (last chunk plus `digest`) - x-codeSamples: - lang: Bash label: cURL @@ -1100,8 +981,6 @@ paths: -H "Content-Type: application/octet-stream" \ --data-binary @layer.tar.gz \ "https://registry-1.docker.io/v2/library/ubuntu/blobs/uploads/abc123?digest=sha256:abcd1234..." - - parameters: - name: name in: path @@ -1124,13 +1003,6 @@ paths: schema: type: string example: sha256:abcd1234... - - name: Authorization - in: header - required: true - schema: - type: string - example: Bearer eyJhbGciOi... - requestBody: required: false content: @@ -1141,8 +1013,7 @@ paths: examples: layer-upload: summary: Layer tarball blob - value: "" - + value: responses: "201": description: Upload completed successfully @@ -1174,7 +1045,6 @@ paths: description: Requested range not satisfiable (if used in chunked mode) "429": description: Too many requests - patch: tags: - Blobs @@ -1216,12 +1086,6 @@ paths: schema: type: string example: abc123 - - name: Authorization - in: header - required: true - schema: - type: string - example: Bearer eyJhbGciOi... - name: Content-Range in: header required: false @@ -1229,7 +1093,6 @@ paths: type: string example: bytes 0-65535 description: Optional. Byte range of the chunk being sent - requestBody: required: true content: @@ -1240,8 +1103,7 @@ paths: examples: chunk-0: summary: Upload chunk 0 of a blob - value: "" - + value: responses: "202": description: Chunk accepted and stored @@ -1288,7 +1150,6 @@ paths: - The client wants to clean up unused upload sessions After cancellation, the UUID is no longer valid and a new `POST` must be issued to restart the upload. - x-codeSamples: - lang: Bash label: cURL @@ -1297,7 +1158,6 @@ paths: curl -X DELETE \ -H "Authorization: Bearer $TOKEN" \ https://registry-1.docker.io/v2/library/ubuntu/blobs/uploads/abc123` - parameters: - name: name in: path @@ -1313,13 +1173,6 @@ paths: schema: type: string example: abc123 - - name: Authorization - in: header - required: true - schema: - type: string - example: Bearer eyJhbGciOi... - responses: "204": description: Upload session cancelled successfully. No body is returned. @@ -1337,8 +1190,6 @@ paths: description: Upload session not found "429": description: Too many requests - - x-tagGroups: - name: General tags: @@ -1351,3 +1202,12 @@ x-tagGroups: tags: - Manifests - Blobs +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +components: + securitySchemes: + registryToken: + type: http + scheme: bearer + description: Follow the WWW-Authenticate challenge and obtain a repository-scoped registry bearer token. Public image pulls can obtain a token without account credentials; the registry request still sends that token. This token exchange is separate from Hub API authentication. +security: + - registryToken: [] diff --git a/hack/api-docs/README.md b/hack/api-docs/README.md new file mode 100644 index 000000000000..5288e522477a --- /dev/null +++ b/hack/api-docs/README.md @@ -0,0 +1,83 @@ +# API reference build + +This tool validates the Hub, DVP, and Registry OpenAPI sources in +`content/reference/api/` and generates presentation data for Hugo. Engine and +AI Governance retain their existing sources and renderers. + +## Commands + +Use the Go version declared in `go.mod`, Hugo, and the site's Node dependencies. +From the repository root: + +```console +$ npm ci +$ ./hack/api-docs/run.sh test +$ ./hack/api-docs/run.sh generate +$ hugo server +``` + +For a static build with HTML/Markdown checks, run +`./hack/api-docs/run.sh build`. To serve that build on port 1314, run +`./hack/api-docs/run.sh serve`. Set `DOCS_URL` and `DOCS_PORT` when using another +address. Run generation again after changing a specification or source manifest entry. + +Docker builds and Netlify deploy previews run generation before Hugo. Generated +data, validation reports, binaries, and local builds go under `tmp/api-reference/`. +Hugo reports an error if the generated data is absent. + +## Processor inputs + +`sources.json` registers input specifications and their product/manual +relationships. Both generation and the validation wrapper use this manifest. +Hugo reads the generated presentation data, rather than this file. Authentication +descriptions come from the specifications and linked guides. + +`validation/` contains the Vacuum rules and locked official +schema resources. `testdata/` contains validation fixtures. The presentation model +version and supported dialect are defined by the processor, not configurable +manifest fields. + +## Pipeline + +1. Parse the authoritative YAML with `libopenapi` and preserve source values and + reference identities. Verify locked OpenAPI dialect resources. +2. Run Vacuum policy checks and JSON Schema validation of schemas and supplied + examples, followed by documentation profile checks. +3. Generate presentation model version 1: operations, effective security and + servers, parameters, media variants, examples, schema links, and provenance. +4. Render HTML and Markdown through the content adapter and `api-docs` templates. +5. Run the shared site flattening script, then check generated operation, + parameter, media, schema, and link coverage. + +The shared `hack/flatten-and-resolve.js` script resolves Markdown link destinations +relative to each original file, then moves `index.md` files to flattened paths. +It preserves code examples and other text. API pages use the same processing as +other pages; links that already use published URLs remain unchanged. + +The published YAML URLs still serve the source files directly. There is no +conversion step, snapshot dependency, Node migration package, or source archive +in the build. + +## Validation + +`check` is strict and reports every documentation profile failure: + +```console +$ ./hack/api-docs/run.sh check +``` + +Generation runs the same strict validation. Any diagnostic fails the build; +there is no exception baseline. Reports are written to +`tmp/api-reference/validation.json`. + +## Tests and scope + +Go fixtures cover dialects, references, recursion, boolean schemas, examples, +security overrides, server and parameter precedence, and request generation. +`verify-output.mjs` checks all generated HTML/Markdown pairs and retention of +Engine v1.40–v1.56 in ReDoc, unchanged Governance rendering, and byte-identical +published specifications. `browser-checks.mjs` exports a Playwright check for +navigation, page aliases, filtering, requests, and narrow screens. + +Callbacks and webhook navigation are unsupported and fail validation. Request +examples are POSIX shell templates; they do not make service calls. diff --git a/hack/api-docs/browser-checks.mjs b/hack/api-docs/browser-checks.mjs new file mode 100644 index 000000000000..a5eb05d5b614 --- /dev/null +++ b/hack/api-docs/browser-checks.mjs @@ -0,0 +1,106 @@ +// Run with a Playwright Page against a built site; no product API calls. +export default async function verify(page, base = "http://localhost:1314") { + const results = []; + const assert = (condition, message) => { + if (!condition) throw Error(message); + results.push(message); + }; + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto(base + "/reference/api/"); + assert( + (await page.locator(".api-card").count()) === 5, + "Five API catalog entries", + ); + assert( + (await page.locator("nav.navbar-font").count()) === 1, + "Catalog retains Reference sidebar", + ); + await page.goto(base + "/reference/api/dvp/latest/"); + assert( + (await page.locator(".api-nav").count()) === 1 && + (await page.locator("nav.navbar-font").count()) === 0, + "Reference uses local navigation", + ); + const first = page.locator("[data-api-filter-item]").first(); + const operationURL = await first.getAttribute("href"); + await page.goto(base + "/reference/api/hub/dvp/"); + await page.waitForURL("**/reference/api/dvp/latest/"); + assert( + page.url().endsWith("/reference/api/dvp/latest/"), + "DVP alias reaches overview", + ); + await page.goto(base + operationURL); + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + const copy = page.locator("[data-api-copy]"); + await copy.focus(); + await page.keyboard.press("Enter"); + await page.waitForFunction( + () => document.querySelector("[data-api-copy]").textContent === "Copied", + ); + assert( + (await page.evaluate(() => navigator.clipboard.readText())).includes( + "curl", + ), + "Keyboard copies a request", + ); + await page.locator(".api-nav-back").focus(); + await page.keyboard.press("Enter"); + await page.waitForURL("**/reference/api/"); + assert( + (await page.locator("nav.navbar-font").count()) === 1, + "Keyboard back link restores catalog", + ); + await page.goto(base + "/reference/api/hub/latest/"); + await page.locator("[data-api-filter]").fill("token"); + const rows = page.locator("[data-api-filter-item]:visible"); + assert( + (await rows.count()) > 0 && + (await rows.allTextContents()).every((x) => + x.toLowerCase().includes("token"), + ), + "Operation filtering", + ); + const schemaURL = await page + .locator(".api-schema-links a") + .first() + .getAttribute("href"); + await page.goto(base + schemaURL); + assert( + (await page.locator('[data-api-view="schema"]').count()) === 1, + "Linked schema page", + ); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(base + operationURL); + assert( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + 1, + ), + "No narrow-screen document overflow", + ); + await page.goto(base + "/reference/api/engine/version/v1.56/"); + assert( + (await page.locator("redoc").count()) === 1, + "Latest Engine retains ReDoc", + ); + await page.goto(base + "/reference/api/ai-governance/"); + assert( + (await page.locator("[data-api-view]").count()) === 0 && + (await page.locator("h1").count()) > 0, + "Governance retains existing renderer", + ); + const context = await page + .context() + .browser() + .newContext({ javaScriptEnabled: false }); + try { + const staticPage = await context.newPage(); + await staticPage.goto(base + operationURL); + assert( + (await staticPage.locator("[data-api-copy-source]").count()) === 1, + "Request content exists without JavaScript", + ); + } finally { + await context.close(); + } + return results; +} diff --git a/hack/api-docs/contracts_test.go b/hack/api-docs/contracts_test.go new file mode 100644 index 000000000000..9163401c934f --- /dev/null +++ b/hack/api-docs/contracts_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +func localDocument(t *testing.T, api string) *Document { + t.Helper() + d, err := loadDocument(filepath.Join("..", "..", "content", "reference", "api", api, "latest.yaml"), "validation/dialects") + if err != nil { + t.Fatal(err) + } + d.Source = Source{ID: api} + d.validate("") + if len(d.Diagnostics) != 0 { + t.Fatalf("%s must pass strict validation: %+v", api, d.Diagnostics) + } + return d +} + +func TestHubResponseContracts(t *testing.T) { + d := localDocument(t, "hub") + for _, tc := range []struct { + pointer, value string + valid bool + }{ + {"/components/schemas/error", `{"message":"not found","errinfo":null}`, true}, + {"/components/schemas/error", `{"errinfo":{"field":["invalid"],"limit":5}}`, true}, + {"/components/schemas/error", `{"errinfo":[]}`, false}, + {"/components/schemas/getAccessTokensResponse", `{"next":null,"previous":null,"results":[]}`, true}, + {"/components/schemas/getOrgAccessTokensResponse", `{"next":null,"previous":null,"results":[]}`, true}, + {"/components/schemas/org_member_paginated", `{"count":0,"next":null,"previous":null,"results":[]}`, true}, + {"/components/schemas/org_member_paginated", `[]`, false}, + {"/components/schemas/bulk_invite", `{"invitees":[]}`, true}, + {"/components/schemas/bulk_invite", `{"invitees":{"invitees":[]}}`, false}, + {"/components/schemas/tag", `{"v2":true,"images":[{"architecture":"amd64","variant":null,"features":null,"os_features":null,"os_version":null}]}`, true}, + {"/components/schemas/tag", `{"v2":"true","images":{}}`, false}, + {"/components/schemas/immutable_tags_verify_request", `{"regex":"v.*"}`, true}, + {"/components/schemas/immutable_tags_verify_request", `{"regex":"v1,v2"}`, false}, + {"/components/schemas/scim_service_provider_config", `{"authenticationSchemes":[{"type":"oauthbearertoken"}]}`, true}, + {"/components/schemas/scim_service_provider_config", `{"authenticationSchemes":{}}`, false}, + {"/components/requestBodies/scim_update_user_request/content/application~1scim+json/schema", `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"user@example.com","active":false}`, true}, + {"/components/requestBodies/scim_update_user_request/content/application~1scim+json/schema", `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}`, false}, + {"/paths/~1v2~1orgs~1{org_name}~1members~1export/get/responses/200/content/text~1csv/schema", `"Name,Username\nUser,dockeruser\n"`, true}, + {"/paths/~1v2~1orgs~1{org_name}~1members~1export/get/responses/200/content/text~1csv/schema", `[{"Name":"User"}]`, false}, + } { + t.Run(tc.pointer+"/"+tc.value, func(t *testing.T) { + s, err := d.Compiler.Compile(d.URI + "#" + tc.pointer) + if err != nil { + t.Fatal(err) + } + if err := s.Validate(jsonNumber(tc.value)); (err == nil) != tc.valid { + t.Fatalf("valid = %t, want %t: %v", err == nil, tc.valid, err) + } + }) + } + for _, response := range []string{"scim_get_resource_types_resp", "scim_get_schemas_resp", "scim_get_users_resp"} { + v, err := pointer(d.Root, "/components/responses/"+response+"/content/application~1scim+json/schema/properties") + if err != nil { + t.Fatal(err) + } + if obj(v)["Resources"] == nil || obj(v)["resources"] != nil { + t.Fatalf("incorrect SCIM list field casing: %s", response) + } + } +} + +func TestRegistryRequests(t *testing.T) { + d := localDocument(t, "registry") + for _, raw := range arr(d.model()["operations"]) { + op := obj(raw) + curl := str(op["curl"]) + if strings.Count(curl, "Authorization:") != 1 || !strings.Contains(curl, "${REGISTRY_TOKEN}") { + t.Errorf("incorrect registry authentication: %s", curl) + } + if strings.Count(curl, "Content-Type:") > 1 { + t.Errorf("duplicate content type: %s", curl) + } + if (op["id"] == "GetImageManifest" || op["id"] == "HeadImageManifest") && !strings.Contains(curl, "Accept: application/vnd.docker.distribution.manifest.v2+json") { + t.Errorf("missing manifest negotiation: %s", curl) + } + if op["id"] == "GetBlobUploadStatus" && !strings.Contains(curl, "--request GET") { + t.Errorf("incorrect upload status method: %s", curl) + } + } +} + +func TestReservedHeaderParameters(t *testing.T) { + for _, name := range []string{"Authorization", "accept", "CONTENT-TYPE", "Content-Range"} { + t.Run(name, func(t *testing.T) { + d := fixture(t, "valid.yaml") + op := obj(obj(obj(d.Root["paths"])["/public"])["get"]) + op["parameters"] = []any{Object{"name": name, "in": "header", "description": "Header value.", "schema": Object{"type": "string"}}} + d.validate("") + found := false + for _, diag := range d.Diagnostics { + found = found || (diag.Rule == "S8" && strings.Contains(diag.Message, "ignores")) + } + if found != (name != "Content-Range") { + t.Fatalf("unexpected header validation: %+v", d.Diagnostics) + } + }) + } +} + +func TestExampleText(t *testing.T) { + for _, tc := range []struct { + media string + value any + text, language string + }{ + {"text/csv", "Name,Username\nUser,dockeruser\n", "Name,Username\nUser,dockeruser\n", "text"}, + {"text/plain", "&\n", "&\n", "text"}, + {"application/json", "a\nb", `"a\nb"`, "json"}, + {"application/scim+json", false, "false", "json"}, + {"application/json", jsonNumber("0"), "0", "json"}, + } { + text, language := exampleText(tc.media, tc.value) + if text != tc.text || language != tc.language { + t.Errorf("got %q (%s), want %q (%s)", text, language, tc.text, tc.language) + } + } +} diff --git a/hack/api-docs/go.mod b/hack/api-docs/go.mod new file mode 100644 index 000000000000..6f45e2016a8f --- /dev/null +++ b/hack/api-docs/go.mod @@ -0,0 +1,18 @@ +module github.com/docker/docs/hack/api-docs + +go 1.26.5 + +require ( + github.com/pb33f/libopenapi v0.38.7 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/pb33f/jsonpath v0.8.2 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/hack/api-docs/go.sum b/hack/api-docs/go.sum new file mode 100644 index 000000000000..304e372c1e29 --- /dev/null +++ b/hack/api-docs/go.sum @@ -0,0 +1,30 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y= +github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= +github.com/pb33f/libopenapi v0.38.7 h1:Q2jfgRPdnU38WW8wQvrX2HEPGiqsxj01PX1BHmAEihc= +github.com/pb33f/libopenapi v0.38.7/go.mod h1:naZ03Auhn7i+RJtMv8ck8l7Ag8E2/x2w66j9vsDFL38= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8= +github.com/pb33f/testify v0.1.0/go.mod h1:nq283P/jJ8hXMmdhAqfj7BJIz0y+6IOHj9q0044rKt4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hack/api-docs/main.go b/hack/api-docs/main.go new file mode 100644 index 000000000000..26a809d93f0d --- /dev/null +++ b/hack/api-docs/main.go @@ -0,0 +1,754 @@ +// api-docs prepares a source-preserving reference model for Docker API documentation. +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi/datamodel" + js "github.com/santhosh-tekuri/jsonschema/v6" + yaml "go.yaml.in/yaml/v4" +) + +type Object = map[string]any + +const dialect = "https://spec.openapis.org/oas/3.1/dialect/base" + +var methods = []string{"get", "put", "post", "delete", "options", "head", "patch", "trace", "query"} + +func obj(v any) Object { + m, _ := v.(map[string]any) + if m == nil { + return Object{} + } + return m +} +func arr(v any) []any { a, _ := v.([]any); return a } +func str(v any) string { s, _ := v.(string); return s } +func keys(m Object) []string { + a := make([]string, 0, len(m)) + for k := range m { + a = append(a, k) + } + sort.Strings(a) + return a +} +func hash(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } +func encoded(v any) []byte { + b, e := json.MarshalIndent(v, "", " ") + if e != nil { + panic(e) + } + return append(b, '\n') +} +func esc(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, "~", "~0"), "/", "~1") } +func pointer(root any, p string) (any, error) { + if p == "" { + return root, nil + } + if !strings.HasPrefix(p, "/") { + return nil, fmt.Errorf("unsupported pointer %s", p) + } + for _, k := range strings.Split(p[1:], "/") { + k = strings.ReplaceAll(strings.ReplaceAll(k, "~1", "/"), "~0", "~") + switch v := root.(type) { + case map[string]any: + var ok bool + root, ok = v[k] + if !ok { + return nil, fmt.Errorf("missing pointer %s", p) + } + case []any: + var n int + if _, e := fmt.Sscanf(k, "%d", &n); e != nil || n < 0 || n >= len(v) { + return nil, fmt.Errorf("invalid array pointer %s", p) + } + root = v[n] + default: + return nil, fmt.Errorf("non-container pointer %s", p) + } + } + return root, nil +} +func readJSON(p string) (any, error) { + b, e := os.ReadFile(p) + if e != nil { + return nil, e + } + return js.UnmarshalJSON(bytes.NewReader(b)) +} +func writeJSON(p string, v any) error { + if e := os.MkdirAll(filepath.Dir(p), 0755); e != nil { + return e + } + return os.WriteFile(p, encoded(v), 0644) +} + +type Diagnostic struct { + Rule string `json:"rule"` + Pointer string `json:"pointer"` + Message string `json:"message"` +} +type Source struct { + ID string `json:"id"` + Product string `json:"product"` + Title string `json:"title"` + Source string `json:"source"` + Owner string `json:"owner"` + Manual string `json:"manual"` + Connection string `json:"connection"` + Guides []string `json:"guides"` +} +type Registry struct{ resources map[string]any } + +func (r *Registry) Load(uri string) (any, error) { + if v, ok := r.resources[uri]; ok { + return v, nil + } + return nil, fmt.Errorf("unlocked resource: %s", uri) +} + +type Document struct { + Root Object + URI string + Registry *Registry + Diagnostics []Diagnostic + Schemas map[string]any + Locations map[string]int + Compiler *js.Compiler + Compiled map[string]*js.Schema + Digest string + Source Source + SchemaCount int + ExampleCount int +} + +func (d *Document) issue(rule, p, msg string) { + d.Diagnostics = append(d.Diagnostics, Diagnostic{Rule: rule, Pointer: p, Message: strings.ReplaceAll(msg, d.URI, "source:"+d.Source.ID)}) +} +func walk(v any, p string, fn func(Object, string)) { + switch n := v.(type) { + case map[string]any: + fn(n, p) + for _, k := range keys(n) { + walk(n[k], p+"/"+esc(k), fn) + } + case []any: + for i, x := range n { + walk(x, fmt.Sprintf("%s/%d", p, i), fn) + } + } +} +func strictSource(file string) (Object, libopenapi.Document, error) { + b, e := os.ReadFile(file) + if e != nil { + return nil, nil, e + } + cfg := datamodel.NewDocumentConfiguration() + cfg.AllowRemoteReferences = false + cfg.AllowFileReferences = false + cfg.SkipExternalRefResolution = true + doc, e := libopenapi.NewDocumentWithConfiguration(b, cfg) + if e != nil { + return nil, nil, e + } + v, e := strictFragment(file) + return obj(v), doc, e +} + +// YAML timestamp scalars are strings in the JSON data model. Decode source nodes +// explicitly: libopenapi's JSON convenience view normalizes their spelling. +func strictFragment(file string) (any, error) { + b, e := os.ReadFile(file) + if e != nil { + return nil, e + } + var node yaml.Node + if e = yaml.Unmarshal(b, &node); e != nil { + return nil, e + } + v, e := sourceValue(&node, map[*yaml.Node]bool{}) + if e != nil { + return nil, e + } + raw, e := json.Marshal(v) + if e != nil { + return nil, e + } + return js.UnmarshalJSON(bytes.NewReader(raw)) +} +func sourceValue(n *yaml.Node, active map[*yaml.Node]bool) (any, error) { + if active[n] { + return nil, errors.New("cyclic YAML alias") + } + active[n] = true + defer delete(active, n) + switch n.Kind { + case yaml.DocumentNode: + if len(n.Content) != 1 { + return nil, errors.New("expected one YAML document") + } + return sourceValue(n.Content[0], active) + case yaml.AliasNode: + return sourceValue(n.Alias, active) + case yaml.MappingNode: + m := Object{} + for i := 0; i < len(n.Content); i += 2 { + k := n.Content[i] + if k.Kind != yaml.ScalarNode || k.Value == "<<" { + return nil, errors.New("complex/merge YAML keys are outside the source profile") + } + if _, ok := m[k.Value]; ok { + return nil, fmt.Errorf("duplicate key %s at line %d", k.Value, k.Line) + } + v, e := sourceValue(n.Content[i+1], active) + if e != nil { + return nil, e + } + m[k.Value] = v + } + return m, nil + case yaml.SequenceNode: + a := []any{} + for _, c := range n.Content { + v, e := sourceValue(c, active) + if e != nil { + return nil, e + } + a = append(a, v) + } + return a, nil + case yaml.ScalarNode: + if n.Tag == "!!str" || n.Tag == "!!timestamp" { + return n.Value, nil + } + var v any + e := n.Decode(&v) + return v, e + } + return nil, errors.New("unsupported YAML node") +} + +func loadDocument(file, metaDir string) (*Document, error) { + abs, _ := filepath.Abs(file) + root, parsed, e := strictSource(abs) + if e != nil { + return nil, e + } + uri := (&url.URL{Scheme: "file", Path: abs}).String() + b, _ := os.ReadFile(file) + d := &Document{Root: root, URI: uri, Diagnostics: []Diagnostic{}, Registry: &Registry{resources: map[string]any{uri: root}}, Schemas: map[string]any{}, Locations: map[string]int{}, Compiled: map[string]*js.Schema{}, Digest: hash(b)} + // libopenapi's typed model supplies operation views/locations; schema serialization is never used. + model, modelErr := parsed.BuildV3Model() + if modelErr == nil && model != nil && model.Model.Paths != nil { + for p, item := range model.Model.Paths.PathItems.FromOldest() { + for m, op := range item.GetOperations().FromOldest() { + if op.GoLow().RootNode != nil { + d.Locations["/paths/"+esc(p)+"/"+m] = op.GoLow().RootNode.Line + } + } + } + } + // All file resources are acquired before compilation, under a bounded source directory. + var acquire func(any, string) error + acquire = func(v any, base string) error { + var failure error + walk(v, "", func(n Object, _ string) { + for _, k := range []string{"$ref", "$dynamicRef"} { + ref, ok := n[k].(string) + if !ok { + return + } + u, er := url.Parse(ref) + if er != nil { + failure = er + return + } + bu, _ := url.Parse(base) + u = bu.ResolveReference(u) + u.Fragment = "" + id := u.String() + if _, ok := d.Registry.resources[id]; ok { + return + } + if u.Scheme != "file" { + failure = fmt.Errorf("unlocked reference %s", id) + return + } + rel, er := filepath.Rel(filepath.Dir(abs), u.Path) + if er != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + failure = fmt.Errorf("reference escapes package: %s", ref) + return + } + r, er := strictFragment(u.Path) + if er != nil { + failure = er + return + } + d.Registry.resources[id] = r + if er = acquire(r, id); er != nil { + failure = er + } + } + }) + return failure + } + if e = acquire(root, uri); e != nil { + return nil, e + } + lockRaw, e := os.ReadFile(filepath.Join(metaDir, "lock.json")) + if e != nil { + return nil, e + } + var lock []struct{ File, URI, SHA256 string } + if e = json.Unmarshal(lockRaw, &lock); e != nil { + return nil, e + } + for _, l := range lock { + b, e := os.ReadFile(filepath.Join(metaDir, l.File)) + if e != nil { + return nil, e + } + if hash(b) != l.SHA256 { + return nil, fmt.Errorf("dialect digest mismatch: %s", l.File) + } + v, e := js.UnmarshalJSON(bytes.NewReader(b)) + if e != nil { + return nil, e + } + d.Registry.resources[l.URI] = v + if id := str(obj(v)["$id"]); id != "" { + d.Registry.resources[id] = v + } + } + d.Compiler = js.NewCompiler() + d.Compiler.DefaultDraft(js.Draft2020) + d.Compiler.UseLoader(d.Registry) + for id, v := range d.Registry.resources { + if e = d.Compiler.AddResource(id, v); e != nil { + return nil, e + } + } + return d, nil +} +func (d *Document) resolve(v any) any { + seen := map[string]bool{} + for { + r := str(obj(v)["$ref"]) + if r == "" { + return v + } + if seen[r] { + return v + } + seen[r] = true + u, e := url.Parse(r) + if e != nil { + return v + } + base, _ := url.Parse(d.URI) + u = base.ResolveReference(u) + frag := u.Fragment + u.Fragment = "" + target, ok := d.Registry.resources[u.String()] + if !ok { + return v + } + resolved, e := pointer(target, frag) + if e != nil { + return v + } + merged := Object{} + for k, x := range obj(resolved) { + merged[k] = x + } + for _, k := range []string{"summary", "description"} { + if x, ok := obj(v)[k]; ok { + merged[k] = x + } + } + if len(merged) == 0 { + return resolved + } + v = merged + } +} +func (d *Document) schemaRoots(v any, p string) { + switch n := v.(type) { + case map[string]any: + for _, k := range keys(n) { + x := n[k] + q := p + "/" + esc(k) + if k == "schemas" && p == "/components" { + for name, s := range obj(x) { + d.Schemas[q+"/"+esc(name)] = s + } + } else if k == "schema" || k == "itemSchema" { + d.Schemas[q] = x + } else if !strings.HasPrefix(k, "x-") && k != "example" && k != "examples" && k != "value" { + d.schemaRoots(x, q) + } + } + case []any: + for i, x := range n { + d.schemaRoots(x, fmt.Sprintf("%s/%d", p, i)) + } + } +} +func (d *Document) validate(metaDir string) { + if d.Root["openapi"] != "3.2.0" { + d.issue("S1", "/openapi", "Expected OpenAPI 3.2.0") + } + if d.Root["jsonSchemaDialect"] != dialect { + d.issue("S1", "/jsonSchemaDialect", "Expected the selected OAS dialect") + } + documentSchema, e := d.Compiler.Compile("https://spec.openapis.org/oas/3.2/schema/2025-09-17") + if e != nil { + d.issue("structure", "", e.Error()) + } else if e = documentSchema.Validate(d.Root); e != nil { + d.issue("structure", "", e.Error()) + } + // OAS Reference Objects also need explicit validation: a document schema cannot prove their target exists. + walk(d.Root, "", func(n Object, p string) { + if r := str(n["$ref"]); r != "" { + u, er := url.Parse(r) + if er != nil { + d.issue("reference", p, er.Error()) + return + } + base, _ := url.Parse(d.URI) + u = base.ResolveReference(u) + frag := u.Fragment + u.Fragment = "" + v, ok := d.Registry.resources[u.String()] + if !ok { + d.issue("reference", p, "Unregistered resource "+r) + } else if strings.HasPrefix(frag, "/") { + if _, er = pointer(v, frag); er != nil { + d.issue("reference", p, er.Error()) + } + } + } + }) + if len(obj(d.Root["webhooks"])) > 0 { + d.issue("capability", "/webhooks", "Webhook navigation is not supported by the reference renderer") + } + walk(d.Root, "", func(n Object, p string) { + if len(obj(n["callbacks"])) > 0 { + d.issue("capability", p+"/callbacks", "Callback navigation is not supported by the reference renderer") + } + }) + d.schemaRoots(d.Root, "") + schemaMeta, e := d.Compiler.Compile(dialect) + if e != nil { + d.issue("dialect", "", e.Error()) + return + } + for _, p := range keys(d.Schemas) { + s := d.Schemas[p] + d.SchemaCount++ + if e = schemaMeta.Validate(s); e != nil { + d.issue("schema", p, e.Error()) + continue + } + compiled, e := d.Compiler.Compile(d.URI + "#" + p) + if e != nil { + d.issue("schema", p, e.Error()) + continue + } + d.Compiled[p] = compiled + d.schemaExamples(s, p) + } + // Media, parameter and header examples are separate from examples nested inside schemas. + walk(d.Root, "", func(n Object, p string) { + schemaKey := "schema" + if _, ok := n["itemSchema"]; ok { + schemaKey = "itemSchema" + } + c := d.Compiled[p+"/"+schemaKey] + if c == nil { + return + } + if v, ok := n["example"]; ok { + d.example(c, v, p+"/example") + } + for _, name := range keys(obj(n["examples"])) { + ex := obj(d.resolve(obj(n["examples"])[name])) + if v, ok := ex["value"]; ok { + d.example(c, v, p+"/examples/"+esc(name)+"/value") + } + if _, ok := ex["externalValue"]; ok { + d.issue("example-external", p+"/examples/"+esc(name), "External example requires a locked media fixture") + } + } + }) + ids := map[string]bool{} + tags := map[string]string{} + for _, raw := range arr(d.Root["tags"]) { + t := obj(raw) + tags[str(t["name"])] = str(t["kind"]) + } + for _, op := range d.operations() { + id := str(op["id"]) + p := str(op["pointer"]) + if id == "" || ids[id] { + d.issue("S4", p, "Operation ID must be present and unique") + } + ids[id] = true + opTags := arr(op["tags"]) + if len(opTags) == 0 || tags[str(opTags[0])] != "nav" { + d.issue("S5", p+"/tags", "The first operation tag must identify a declared navigation group") + } + for _, tag := range opTags { + if _, ok := tags[str(tag)]; !ok { + d.issue("S5", p+"/tags", "Operation tag is undeclared: "+str(tag)) + } + } + if len(arr(op["servers"])) == 0 && d.Source.Connection != "unix" { + d.issue("S6", p, "Operation needs effective servers or a local connection profile") + } + if strings.TrimSpace(str(op["description"])) == "" { + d.issue("S4", p+"/description", "Operation description required") + } + for _, pr := range arr(op["parameters"]) { + param := obj(pr) + if param["in"] == "header" { + switch strings.ToLower(str(param["name"])) { + case "authorization", "accept", "content-type": + d.issue("S8", str(param["pointer"]), "OpenAPI ignores this header parameter; use security or media types") + } + } + if str(param["description"]) == "" { + d.issue("S8", str(param["pointer"])+"/description", "Parameter description requires editorial review") + } + } + for _, variant := range arr(op["variants"]) { + v := obj(variant) + if str(v["media"]) != "" && len(arr(v["examples"])) == 0 { + d.issue("S11", str(v["pointer"]), "Media variant needs a reviewed example or transfer fixture") + } + } + } +} +func (d *Document) schemaExamples(s any, p string) { + n := obj(s) + if len(n) == 0 { + return + } + if _, ok := n["$schema"]; ok && n["$schema"] != dialect { + d.issue("S1", p+"/$schema", "Schema dialect override is outside the profile") + } + c, e := d.Compiler.Compile(d.URI + "#" + p) + if e == nil { + if v, ok := n["example"]; ok { + d.example(c, v, p+"/example") + } + for i, v := range arr(n["examples"]) { + d.example(c, v, fmt.Sprintf("%s/examples/%d", p, i)) + } + } + for k, x := range n { + switch k { + case "properties", "patternProperties", "$defs", "dependentSchemas": + for _, name := range keys(obj(x)) { + d.schemaExamples(obj(x)[name], p+"/"+k+"/"+esc(name)) + } + case "allOf", "anyOf", "oneOf", "prefixItems": + for i, v := range arr(x) { + d.schemaExamples(v, fmt.Sprintf("%s/%s/%d", p, k, i)) + } + case "items", "additionalProperties", "unevaluatedProperties", "contains", "not", "if", "then", "else", "contentSchema", "propertyNames": + d.schemaExamples(x, p+"/"+k) + } + } +} +func (d *Document) example(c *js.Schema, v any, p string) { + d.ExampleCount++ + if e := c.Validate(v); e != nil { + d.issue("example", p, e.Error()) + } +} +func (d *Document) effective(root, item, op Object, k string) any { + if v, ok := op[k]; ok { + return v + } + if k == "servers" { + if v, ok := item[k]; ok { + return v + } + } + return root[k] +} +func (d *Document) parameters(item, op Object, p string) []any { + a := []any{} + positions := map[string]int{} + for i, container := range []Object{item, op} { + for j, raw := range arr(container["parameters"]) { + v := obj(d.resolve(raw)) + c := Object{} + for k, x := range v { + c[k] = x + } + prefix := p + if i == 0 { + prefix = p[:strings.LastIndex(p, "/")] + } + c["pointer"] = fmt.Sprintf("%s/parameters/%d", prefix, j) + key := str(v["in"]) + ":" + str(v["name"]) + if n, ok := positions[key]; ok { + a[n] = c + } else { + positions[key] = len(a) + a = append(a, c) + } + } + } + return a +} +func exampleList(v Object) []any { + out := []any{} + if x, ok := v["example"]; ok { + out = append(out, Object{"name": "Example", "value": x}) + } + for _, name := range keys(obj(v["examples"])) { + x := obj(obj(v["examples"])[name]) + if value, ok := x["value"]; ok { + out = append(out, Object{"name": name, "value": value}) + } + } + return out +} +func (d *Document) variants(op Object, p string) []any { + out := []any{} + add := func(direction, status string, raw any, ptr string) { + container := obj(d.resolve(raw)) + content := obj(container["content"]) + if len(content) == 0 { + out = append(out, Object{"direction": direction, "status": status, "description": container["description"], "headers": container["headers"], "media": "", "pointer": ptr, "examples": []any{}}) + } + for _, media := range keys(content) { + v := obj(content[media]) + a := Object{"direction": direction, "status": status, "description": container["description"], "headers": container["headers"], "media": media, "pointer": ptr + "/content/" + esc(media), "examples": d.mediaExamples(v), "required": container["required"]} + for _, k := range []string{"schema", "itemSchema", "encoding"} { + if s, ok := v[k]; ok { + a[k] = s + } + } + out = append(out, a) + } + } + if v, ok := op["requestBody"]; ok { + add("Request", "", v, p+"/requestBody") + } + for _, code := range keys(obj(op["responses"])) { + add("Response", code, obj(op["responses"])[code], p+"/responses/"+esc(code)) + } + return out +} +func (d *Document) operations() []Object { + out := []Object{} + for _, path := range keys(obj(d.Root["paths"])) { + item := obj(d.resolve(obj(d.Root["paths"])[path])) + ms := append([]string{}, methods...) + for m := range obj(item["additionalOperations"]) { + ms = append(ms, m) + } + for _, method := range ms { + raw, ok := item[method] + extra := !ok + if !ok { + raw, ok = obj(item["additionalOperations"])[method] + } + if !ok { + continue + } + op := obj(raw) + p := "/paths/" + esc(path) + "/" + method + if extra { + p = "/paths/" + esc(path) + "/additionalOperations/" + esc(method) + } + v := Object{"id": op["operationId"], "method": strings.ToUpper(method), "path": path, "pointer": p, "line": d.Locations[p], "summary": op["summary"], "description": op["description"], "tags": op["tags"], "deprecated": op["deprecated"], "servers": d.effective(d.Root, item, op, "servers"), "security": d.effective(d.Root, item, op, "security"), "parameters": d.parameters(item, op, p), "variants": d.variants(op, p), "raw": op} + out = append(out, v) + } + } + return out +} +func main() { + if e := run(); e != nil { + fmt.Fprintln(os.Stderr, e) + os.Exit(1) + } +} +func run() error { + if len(os.Args) < 3 { + return errors.New("usage: api-docs check|generate|inspect|sources ROOT") + } + command := os.Args[1] + if command != "check" && command != "generate" && command != "inspect" && command != "sources" { + return fmt.Errorf("unknown command: %s", command) + } + if len(os.Args) > 3 { + return errors.New("unexpected arguments") + } + root, _ := filepath.Abs(os.Args[2]) + dir := filepath.Join(root, "hack/api-docs") + meta := filepath.Join(dir, "validation", "dialects") + manifestRaw, e := os.ReadFile(filepath.Join(dir, "sources.json")) + if e != nil { + return e + } + var manifest struct { + APIs []Source `json:"apis"` + LegacyAPIs []Object `json:"legacyAPIs"` + } + if e = json.Unmarshal(manifestRaw, &manifest); e != nil { + return e + } + if command == "sources" { + for _, src := range manifest.APIs { + fmt.Printf("%s\t%s\n", src.ID, src.Source) + } + return nil + } + reports := []any{} + models := []any{} + blocking := 0 + for _, src := range manifest.APIs { + d, e := loadDocument(filepath.Join(root, src.Source), meta) + if e != nil { + return fmt.Errorf("%s: %w", src.ID, e) + } + d.Source = src + d.validate(meta) + blocking += len(d.Diagnostics) + reports = append(reports, Object{"api": src.ID, "sha256": d.Digest, "schemas": d.SchemaCount, "examples": d.ExampleCount, "operations": len(d.operations()), "diagnostics": d.Diagnostics}) + models = append(models, d.model()) + fmt.Printf("%s: %d operations, %d schema roots, %d examples, %d diagnostics\n", src.ID, len(d.operations()), d.SchemaCount, d.ExampleCount, len(d.Diagnostics)) + } + out := filepath.Join(root, "tmp/api-reference") + if e = writeJSON(filepath.Join(out, "validation.json"), reports); e != nil { + return e + } + if command == "inspect" { + return nil + } + if blocking > 0 { + return fmt.Errorf("%d blocking diagnostics; see tmp/api-reference/validation.json", blocking) + } + if command == "generate" { + if e = writeJSON(filepath.Join(out, "data/api-reference.json"), Object{"modelVersion": 1, "apis": models, "legacyAPIs": manifest.LegacyAPIs}); e != nil { + return e + } + } + return nil +} diff --git a/hack/api-docs/main_test.go b/hack/api-docs/main_test.go new file mode 100644 index 000000000000..a905bc8dfbac --- /dev/null +++ b/hack/api-docs/main_test.go @@ -0,0 +1,285 @@ +package main + +import ( + js "github.com/santhosh-tekuri/jsonschema/v6" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func fixture(t *testing.T, name string) *Document { + t.Helper() + d, e := loadDocument(filepath.Join("testdata", name), "validation/dialects") + if e != nil { + t.Fatal(e) + } + d.Source = Source{ID: "test"} + return d +} +func TestActualDialectAndReferences(t *testing.T) { + d := fixture(t, "valid.yaml") + d.validate("") + for _, x := range d.Diagnostics { + t.Errorf("%s: %s", x.Rule, x.Message) + } + if len(d.operations()) != 4 { + t.Fatal("operation omitted") + } + var query Object + for _, op := range d.operations() { + if op["method"] == "QUERY" { + query = op + } + } + if query == nil { + t.Fatal("QUERY omitted") + } + if obj(arr(query["servers"])[0])["url"] != "https://search.example.test" { + t.Fatal("path server override lost") + } + p := obj(arr(query["parameters"])[0]) + if p["example"] != jsonNumber("0") { + t.Fatalf("parameter override/zero lost: %#v", p["example"]) + } + if len(arr(query["parameters"])) != 1 { + t.Fatal("duplicate overridden parameter") + } +} +func jsonNumber(s string) any { + v, e := js.UnmarshalJSON(strings.NewReader(s)) + if e != nil { + panic(e) + } + return v +} +func TestNegativeFixtures(t *testing.T) { + for _, tc := range []struct{ name, rule string }{{"invalid-info.yaml", "structure"}, {"invalid-item-type.yaml", "schema"}, {"invalid-example.yaml", "example"}, {"profile-missing-id.yaml", "S4"}} { + t.Run(tc.name, func(t *testing.T) { + d := fixture(t, tc.name) + d.validate("") + found := false + for _, x := range d.Diagnostics { + found = found || x.Rule == tc.rule + } + if !found { + t.Fatalf("missing %s diagnostic: %+v", tc.rule, d.Diagnostics) + } + }) + } + for _, name := range []string{"duplicate-key.yaml", "invalid-stream-ref.yaml"} { + t.Run(name, func(t *testing.T) { + d, e := loadDocument(filepath.Join("testdata", name), "validation/dialects") + if e == nil { + d.validate("") + for _, x := range d.Diagnostics { + if x.Rule == "reference" || x.Rule == "schema" { + return + } + } + t.Fatal("invalid source accepted") + } + }) + } +} +func TestSchemaSemantics(t *testing.T) { + d := fixture(t, "valid.yaml") + c := d.Compiler + cases := []struct { + name string + schema any + valid, invalid any + }{{"boolean false", false, nil, "x"}, {"ref siblings", Object{"$defs": Object{"base": Object{"type": "object", "required": []any{"a"}}}, "$ref": "#/$defs/base", "required": []any{"b"}}, Object{"a": true, "b": true}, Object{"b": true}}, {"recursive", Object{"type": "object", "properties": Object{"next": Object{"$ref": "#"}}}, Object{"next": Object{}}, Object{"next": 1}}, {"anchor", Object{"$defs": Object{"x": Object{"$anchor": "x", "type": "boolean"}}, "$ref": "#x"}, false, "false"}, {"dynamic", Object{"$dynamicAnchor": "node", "type": "object", "properties": Object{"child": Object{"$dynamicRef": "#node"}}}, Object{"child": Object{}}, Object{"child": false}}} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + uri := "https://fixtures.test/" + strings.ReplaceAll(tc.name, " ", "-") + s := tc.schema + if m, ok := s.(map[string]any); ok { + m["$schema"] = dialect + } + if e := c.AddResource(uri, s); e != nil { + t.Fatal(e) + } + compiled, e := c.Compile(uri) + if e != nil { + t.Fatal(e) + } + if tc.valid != nil { + if e = compiled.Validate(tc.valid); e != nil { + t.Fatal(e) + } + } + if e = compiled.Validate(tc.invalid); e == nil { + t.Fatal("invalid example accepted") + } + }) + } +} +func TestAuthAndCurl(t *testing.T) { + schemes := Object{"bearer": Object{"type": "http", "scheme": "bearer"}, "key": Object{"type": "apiKey", "in": "header", "name": "X-Key"}} + op := Object{"method": "GET", "path": "/flag/{id}", "servers": []any{Object{"url": "https://api.test"}}, "securitySchemes": schemes, "security": []any{Object{"bearer": []any{}, "key": []any{}}, Object{}}, "parameters": []any{Object{"name": "id", "in": "path", "required": true, "example": "a/b"}, Object{"name": "enabled", "in": "query", "example": false}, Object{"name": "limit", "in": "query", "example": 0}}} + curl, _ := curlExample(Source{}, op) + for _, want := range []string{"a%2Fb", "enabled=false", "limit=0", "${TOKEN}", "X-Key: ${API_KEY}"} { + if !strings.Contains(curl, want) { + t.Errorf("missing %s: %s", want, curl) + } + } + op["security"] = []any{} + curl, _ = curlExample(Source{}, op) + if strings.Contains(curl, "Authorization") { + t.Fatal("anonymous operation gained auth") + } + op["method"] = "HEAD" + curl, _ = curlExample(Source{Connection: "unix", ID: "engine-1.56"}, op) + if !strings.Contains(curl, "--head") || !strings.Contains(curl, "--unix-socket") { + t.Fatal(curl) + } + d := &Document{} + root := Object{"security": []any{Object{"bearer": []any{}}}} + if !reflect.DeepEqual(d.effective(root, Object{}, Object{"security": []any{}}, "security"), []any{}) { + t.Fatal("empty override lost") + } +} +func TestFalseSchemaAndExamplePreserved(t *testing.T) { + b, e := os.ReadFile("testdata/valid.yaml") + if e != nil { + t.Fatal(e) + } + tmp := t.TempDir() + if e = os.WriteFile(filepath.Join(tmp, "spec.yaml"), []byte(strings.ReplaceAll(string(b), "schema: true", "schema: false")), 0600); e != nil { + t.Fatal(e) + } + if e = os.Mkdir(filepath.Join(tmp, "schemas"), 0700); e != nil { + t.Fatal(e) + } + event, _ := os.ReadFile("testdata/schemas/event.yaml") + os.WriteFile(filepath.Join(tmp, "schemas/event.yaml"), event, 0600) + d, e := loadDocument(filepath.Join(tmp, "spec.yaml"), "validation/dialects") + if e != nil { + t.Fatal(e) + } + d.validate("") + v, e := pointer(d.Root, "/paths/~1search/query/responses/200/content/application~1json/schema") + if e != nil || v != false { + t.Fatal("false schema changed") + } + for _, op := range d.operations() { + if op["path"] == "/public" { + ex := obj(arr(obj(arr(op["variants"])[0])["examples"])[0]) + if ex["value"] != false { + t.Fatal("false example omitted") + } + } + } +} + +func TestMediaExamplesFollowReferenceAnnotations(t *testing.T) { + d := &Document{URI: "https://example.test/api.yaml", Registry: &Registry{resources: map[string]any{ + "https://example.test/api.yaml": Object{ + "alias": Object{"$ref": "#/base", "examples": []any{false, jsonNumber("0")}}, + "base": Object{"example": "base example"}, + "cycle": Object{"$ref": "#/cycle"}, + }, + "https://example.test/schemas/alias.yaml": Object{"$ref": "base.yaml"}, + "https://example.test/schemas/base.yaml": Object{"example": "external example"}, + }}} + for _, tc := range []struct { + name string + media Object + values []any + }{ + {"intermediate annotations", Object{"schema": Object{"$ref": "#/alias"}}, []any{false, jsonNumber("0")}}, + {"nearest schema annotation", Object{"schema": Object{"$ref": "#/alias", "example": "local"}}, []any{"local"}}, + {"media annotation", Object{"example": false, "schema": Object{"$ref": "#/alias"}}, []any{false}}, + {"relative external reference", Object{"schema": Object{"$ref": "schemas/alias.yaml"}}, []any{"external example"}}, + {"cycle", Object{"schema": Object{"$ref": "#/cycle"}}, []any{}}, + } { + t.Run(tc.name, func(t *testing.T) { + values := []any{} + for _, ex := range d.mediaExamples(tc.media) { + values = append(values, obj(ex)["value"]) + } + if !reflect.DeepEqual(values, tc.values) { + t.Fatalf("examples = %#v, want %#v", values, tc.values) + } + }) + } +} + +func TestDVPResponseContracts(t *testing.T) { + d, err := loadDocument(filepath.Join("..", "..", "content", "reference", "api", "dvp", "latest.yaml"), "validation/dialects") + if err != nil { + t.Fatal(err) + } + d.Source = Source{ID: "dvp"} + d.validate("") + if len(d.Diagnostics) != 0 { + t.Fatalf("DVP must pass without exceptions: %+v", d.Diagnostics) + } + for _, tc := range []struct { + schema string + value string + valid bool + }{ + {"TimespanModel", `{"month":7}`, true}, + {"TimespanModel", `{"week":31}`, true}, + {"TimespanModel", `{}`, false}, + {"TimespanModel", `{"month":7,"week":31}`, false}, + {"TimespanModel", `{"month":{"month":7}}`, false}, + {"TimespanModel", `7`, false}, + {"TimespanData", `{"months":[{"month":5},{"month":7}]}`, true}, + {"TimespanData", `{"weeks":[]}`, true}, + {"TimespanData", `{"months":[],"weeks":[]}`, false}, + {"TimespanData", `{"months":[{}]}`, false}, + {"TimespanData", `{"weeks":null}`, false}, + {"NamespaceMetadata", `{"namespace":"org1","extraRepos":null,"extensionPublisher":false}`, true}, + {"PullData", `{"pulls":null}`, true}, + {"PullData", `{"pulls":[{"start":"2022-08-01T00:00:00Z","pullCount":0}]}`, true}, + } { + t.Run(tc.schema+"/"+tc.value, func(t *testing.T) { + s, err := d.Compiler.Compile(d.URI + "#/components/schemas/" + tc.schema) + if err != nil { + t.Fatal(err) + } + value, err := js.UnmarshalJSON(strings.NewReader(tc.value)) + if err != nil { + t.Fatal(err) + } + if err := s.Validate(value); (err == nil) != tc.valid { + t.Fatalf("valid = %t, want %t: %v", err == nil, tc.valid, err) + } + }) + } +} + +func TestTimestampStringsRetainSpelling(t *testing.T) { + p := filepath.Join(t.TempDir(), "schema.yaml") + if e := os.WriteFile(p, []byte("type: string\nexample: 2021-01-05T21:06:53.506400Z\n"), 0600); e != nil { + t.Fatal(e) + } + v, e := strictFragment(p) + if e != nil { + t.Fatal(e) + } + if obj(v)["example"] != "2021-01-05T21:06:53.506400Z" { + t.Fatalf("timestamp spelling changed: %v", v) + } +} +func TestCaseSensitiveSchemaRoutes(t *testing.T) { + if slug("Error") == slug("error") { + t.Fatal("schema route collision") + } +} +func TestUnlockedResourcesAndUnknownDialect(t *testing.T) { + d := fixture(t, "valid.yaml") + if _, e := d.Compiler.Compile("https://unlocked.test/schema"); e == nil { + t.Fatal("unlocked schema fetched") + } + if e := d.Compiler.AddResource("https://fixtures.test/custom", Object{"$schema": "https://unlocked.test/dialect", "type": "object"}); e != nil { + t.Fatal(e) + } + if _, e := d.Compiler.Compile("https://fixtures.test/custom"); e == nil { + t.Fatal("unknown dialect accepted") + } +} diff --git a/hack/api-docs/model.go b/hack/api-docs/model.go new file mode 100644 index 000000000000..b7d519e77991 --- /dev/null +++ b/hack/api-docs/model.go @@ -0,0 +1,293 @@ +package main + +import ( + "net/url" + "sort" + "strings" +) + +func route(id string) string { + return "/reference/api/" + id + "/latest/" +} +func slug(s string) string { return url.PathEscape(s) } +func (d *Document) model() Object { + ops := d.operations() + schemas := []any{} + schemaURLs := Object{} + for _, name := range keys(obj(obj(d.Root["components"])["schemas"])) { + p := "/components/schemas/" + esc(name) + u := route(d.Source.ID) + "schemas/" + slug(name) + "/" + schemaURLs["#"+p] = u + schemas = append(schemas, Object{"name": name, "pointer": p, "url": u, "schema": obj(obj(d.Root["components"])["schemas"])[name]}) + } + for _, op := range ops { + for _, raw := range arr(op["variants"]) { + v := obj(raw) + for _, rawEx := range arr(v["examples"]) { + ex := obj(rawEx) + ex["text"], ex["language"] = exampleText(str(v["media"]), ex["value"]) + } + } + } + for _, op := range ops { + op["url"] = route(d.Source.ID) + "operations/" + slug(str(op["id"])) + "/" + op["securitySchemes"] = obj(d.Root["components"])["securitySchemes"] + op["acceptMedia"] = responseMedia(op) + // HEAD negotiates the GET representation but has no response body. + if op["method"] == "HEAD" && op["acceptMedia"] == "" { + for _, get := range ops { + if get["method"] == "GET" && get["path"] == op["path"] { + op["acceptMedia"] = responseMedia(get) + break + } + } + } + op["curl"], op["curlNotes"] = curlExample(d.Source, op) + op["references"] = refs(op["raw"], schemaURLs) + op["requestSchema"] = firstRequestSchema(op) + } + return Object{"id": d.Source.ID, "product": d.Source.Product, "title": d.Source.Title, "version": obj(d.Root["info"])["version"], "description": obj(d.Root["info"])["description"], "url": route(d.Source.ID), "manual": d.Source.Manual, "guides": d.Source.Guides, "connection": d.Source.Connection, "servers": d.Root["servers"], "securitySchemes": obj(d.Root["components"])["securitySchemes"], "tags": d.Root["tags"], "operations": ops, "schemas": schemas, "schemaURLs": schemaURLs, "digest": d.Digest, "owner": d.Source.Owner, "source": d.Source.Source, "sourceURL": "/" + strings.TrimPrefix(d.Source.Source, "content/"), "diagnostics": d.Diagnostics, "schemaCount": d.SchemaCount, "exampleCount": d.ExampleCount} +} +func refs(v any, urls Object) []any { + found := map[string]bool{} + walk(v, "", func(n Object, _ string) { + if s := str(n["$ref"]); s != "" { + found[s] = true + } + }) + out := []any{} + names := []string{} + for n := range found { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + out = append(out, Object{"ref": n, "url": urls[n]}) + } + return out +} +func firstRequestSchema(op Object) any { + for _, v := range arr(op["variants"]) { + m := obj(v) + if m["direction"] == "Request" { + return m["schema"] + } + } + return nil +} +func shell(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } +func scalar(v any) string { + if s, ok := v.(string); ok { + return s + } + return strings.TrimSpace(string(encoded(v))) +} +func parameterValue(p Object) (any, bool) { + if v, ok := p["example"]; ok { + return v, true + } + s := obj(p["schema"]) + if v, ok := s["example"]; ok { + return v, true + } + if a := arr(s["examples"]); len(a) > 0 { + return a[0], true + } + return nil, false +} + +func exampleText(media string, value any) (string, string) { + if s, ok := value.(string); ok && !strings.Contains(media, "json") { + return s, "text" + } + return strings.TrimSpace(string(encoded(value))), "json" +} + +func responseMedia(op Object) string { + for _, raw := range arr(op["variants"]) { + v := obj(raw) + if v["direction"] == "Response" && strings.HasPrefix(str(v["status"]), "2") && str(v["media"]) != "" { + return str(v["media"]) + } + } + return "" +} + +func curlExample(src Source, op Object) (string, []string) { + notes := []string{} + args := []string{"curl"} + if op["method"] == "HEAD" { + args = append(args, "--head") + } else { + args = append(args, "--request "+str(op["method"])) + } + server := "" + if a := arr(op["servers"]); len(a) > 0 { + m := obj(a[0]) + server = str(m["url"]) + for name, v := range obj(m["variables"]) { + server = strings.ReplaceAll(server, "{"+name+"}", str(obj(v)["default"])) + } + } + if src.Connection == "unix" { + args = append(args, "--unix-socket \"${DOCKER_SOCKET:-/var/run/docker.sock}\"") + if !strings.HasPrefix(server, "/") { + server = "/v" + strings.TrimPrefix(src.ID, "engine-") + } + server = "http://localhost" + server + } + if server == "" { + server = "https://" + notes = append(notes, "Set the API server address.") + } + target := strings.TrimRight(server, "/") + str(op["path"]) + query := []string{} + for _, raw := range arr(op["parameters"]) { + p := obj(raw) + name := str(p["name"]) + v, has := parameterValue(p) + required, _ := p["required"].(bool) + if !has && !required { + continue + } + value := "<" + strings.ToUpper(name) + ">" + if has { + value = scalar(v) + } + location := str(p["in"]) + if location == "path" { + part := value + if has { + part = url.PathEscape(value) + } + target = strings.ReplaceAll(target, "{"+name+"}", part) + } else if location == "header" { + args = append(args, "--header "+shell(name+": "+value)) + } else if location == "query" { + style := str(p["style"]) + if style == "" { + style = "form" + } + explode := true + if x, ok := p["explode"].(bool); ok { + explode = x + } + if list, ok := v.([]any); ok && style == "form" { + parts := []string{} + for _, x := range list { + parts = append(parts, scalar(x)) + } + if explode { + for _, x := range parts { + query = append(query, url.QueryEscape(name)+"="+url.QueryEscape(x)) + } + } else { + query = append(query, url.QueryEscape(name)+"="+url.QueryEscape(strings.Join(parts, ","))) + } + } else if len(obj(v)) > 0 || style != "form" { + notes = append(notes, "Serialize "+name+" using its documented "+style+" rules; this parameter is not generated.") + } else { + query = append(query, url.QueryEscape(name)+"="+url.QueryEscape(value)) + } + } else { + notes = append(notes, "Supply "+name+" using its documented "+location+" encoding.") + } + } + if len(query) > 0 { + target += "?" + strings.Join(query, "&") + } + security := arr(op["security"]) + if len(security) > 0 { + first := obj(security[0]) + for _, scheme := range keys(first) { + definition := obj(obj(op["securitySchemes"])[scheme]) + switch { + case definition["type"] == "http" && definition["scheme"] == "bearer": + token := "TOKEN" + if scheme == "scimToken" { + token = "SCIM_TOKEN" + } + if scheme == "registryToken" { + token = "REGISTRY_TOKEN" + } + args = append(args, "--header \"Authorization: Bearer ${"+token+"}\"") + case definition["type"] == "apiKey" && definition["in"] == "header": + args = append(args, "--header \""+str(definition["name"])+": ${API_KEY}\"") + default: + notes = append(notes, "Configure authentication scheme "+scheme+" using its documented transport or credential format.") + } + } + if len(security) > 1 { + notes = append(notes, "This example uses the first authentication alternative. Review the complete requirements.") + } + } + if media := str(op["acceptMedia"]); media != "" { + args = append(args, "--header "+shell("Accept: "+media)) + } + for _, raw := range arr(op["variants"]) { + v := obj(raw) + if v["direction"] != "Request" { + continue + } + media := str(v["media"]) + if media == "" { + continue + } + args = append(args, "--header "+shell("Content-Type: "+media)) + examples := arr(v["examples"]) + if len(examples) > 0 && strings.Contains(media, "json") { + args = append(args, "--data-raw "+shell(strings.TrimSpace(string(encoded(obj(examples[0])["value"]))))) + } else { + args = append(args, "--data-binary @request-body") + notes = append(notes, "Prepare request-body using the selected media type and schema.") + } + break + } + args = append(args, shell(target)) + return strings.Join(args, " \\\n "), notes +} + +// Examples are annotations: retrieve them without flattening schema constraints. +func (d *Document) mediaExamples(v Object) []any { + out := exampleList(v) + if len(out) > 0 { + return out + } + s := v["schema"] + seen := map[string]bool{} + base, _ := url.Parse(d.URI) + for s != nil { + n := obj(s) + if x, ok := n["example"]; ok { + return []any{Object{"name": "Schema example", "value": x}} + } + if xs := arr(n["examples"]); len(xs) > 0 { + for _, x := range xs { + out = append(out, Object{"name": "Schema example", "value": x}) + } + return out + } + ref := str(n["$ref"]) + if ref == "" { + break + } + u, err := url.Parse(ref) + if err != nil { + break + } + u = base.ResolveReference(u) + if seen[u.String()] { + break + } + seen[u.String()] = true + fragment := u.Fragment + u.Fragment = "" + // Follow one reference at a time so intermediate schema annotations survive. + s, err = pointer(d.Registry.resources[u.String()], fragment) + if err != nil { + break + } + base = u + } + return out +} diff --git a/hack/api-docs/run.sh b/hack/api-docs/run.sh new file mode 100755 index 000000000000..e132d20253b0 --- /dev/null +++ b/hack/api-docs/run.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +cd "$ROOT" +BIN="$ROOT/tmp/api-reference/bin" +mkdir -p "$BIN" "$ROOT/tmp/api-reference/reports" +export GOWORK=off +bootstrap() { + (cd hack/api-docs && go build -o "$BIN/api-docs" .) + if [[ ! -x "$BIN/vacuum-v0.30.3" ]]; then + GOBIN="$BIN" go install github.com/daveshanley/vacuum@v0.30.3 + mv "$BIN/vacuum" "$BIN/vacuum-v0.30.3" + fi +} +policy() { + local sources + sources=$("$BIN/api-docs" sources "$ROOT") + while IFS=$'\t' read -r api source; do + "$BIN/vacuum-v0.30.3" lint --no-update-check --remote=false --ruleset hack/api-docs/validation/rules.yaml --fail-severity error --min-score 0 --no-banner --no-style --details "$source" > "tmp/api-reference/reports/$api-vacuum.txt" 2>&1 || { + cat "tmp/api-reference/reports/$api-vacuum.txt"; return 1; + } + done <<< "$sources" +} +generate() { + bootstrap + policy + "$BIN/api-docs" generate "$ROOT" +} +case "${1:-build}" in + bootstrap) bootstrap ;; + check) bootstrap; policy; "$BIN/api-docs" check "$ROOT" ;; + generate) generate ;; + test) (cd hack/api-docs && go test ./...) ;; + build|serve) + generate + hugo --destination tmp/api-reference/site --baseURL "${DOCS_URL:-http://localhost:1314}" --cleanDestinationDir + node hack/flatten-and-resolve.js tmp/api-reference/site + node hack/api-docs/verify-output.mjs tmp/api-reference/site + if [[ "${1:-build}" == serve ]]; then + exec python3 -m http.server "${DOCS_PORT:-1314}" --bind 127.0.0.1 --directory tmp/api-reference/site + fi + ;; + *) printf '%s\n' 'Usage: run.sh bootstrap|check|generate|test|build|serve' >&2; exit 2 ;; +esac diff --git a/hack/api-docs/sources.json b/hack/api-docs/sources.json new file mode 100644 index 000000000000..d10f149e096d --- /dev/null +++ b/hack/api-docs/sources.json @@ -0,0 +1,48 @@ +{ + "apis": [ + { + "id": "hub", + "product": "hub", + "title": "Docker Hub", + "source": "content/reference/api/hub/latest.yaml", + "owner": "docker/docs", + "manual": "/manuals/docker-hub/", + "connection": "hosted", + "guides": ["/manuals/security/provisioning/scim/provision-scim.md"] + }, + { + "id": "dvp", + "product": "dvp", + "title": "Publisher analytics", + "source": "content/reference/api/dvp/latest.yaml", + "owner": "docker/docs", + "manual": "/manuals/docker-hub/repos/manage/trusted-content/insights-analytics.md", + "connection": "hosted", + "guides": [] + }, + { + "id": "registry", + "product": "registry", + "title": "Registry", + "source": "content/reference/api/registry/latest.yaml", + "owner": "docker/docs", + "manual": "/manuals/docker-hub/repos/", + "connection": "hosted", + "guides": ["/reference/api/registry/auth.md"] + } + ], + "legacyAPIs": [ + { + "title": "AI Governance", + "url": "/reference/api/ai-governance/", + "connection": "hosted", + "description": "Organization policy and access controls." + }, + { + "title": "Docker Engine", + "url": "/reference/api/engine/", + "connection": "unix", + "description": "Manage containers, images, networks, and volumes." + } + ] +} diff --git a/hack/api-docs/testdata/duplicate-key.yaml b/hack/api-docs/testdata/duplicate-key.yaml new file mode 100644 index 000000000000..739c796ab82b --- /dev/null +++ b/hack/api-docs/testdata/duplicate-key.yaml @@ -0,0 +1,102 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + version: '1' + version: '2' + description: Component capability fixture. +servers: +- url: https://api.example.test/v1 +security: +- bearerAuth: [] +tags: +- name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: ./schemas/event.yaml + /search: + parameters: + - name: limit + in: query + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + parameters: + - name: limit + in: query + schema: + type: integer + example: 0 + responses: + '200': + description: Search result + content: + application/json: + schema: true + /public: + get: + operationId: getPublic + tags: + - operations + summary: Public result + security: [] + responses: + '200': + description: A flag + content: + application/json: + schema: + type: boolean + example: false + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + responses: + '200': + description: Item + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + description: Description beside a reference. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: '#/components/schemas/Item' + - type: 'null' diff --git a/hack/api-docs/testdata/invalid-example.yaml b/hack/api-docs/testdata/invalid-example.yaml new file mode 100644 index 000000000000..37015deb1a44 --- /dev/null +++ b/hack/api-docs/testdata/invalid-example.yaml @@ -0,0 +1,101 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + version: '1' + description: Component capability fixture. +servers: +- url: https://api.example.test/v1 +security: +- bearerAuth: [] +tags: +- name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: ./schemas/event.yaml + /search: + parameters: + - name: limit + in: query + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + parameters: + - name: limit + in: query + schema: + type: integer + example: 0 + responses: + '200': + description: Search result + content: + application/json: + schema: true + /public: + get: + operationId: getPublic + tags: + - operations + summary: Public result + security: [] + responses: + '200': + description: A flag + content: + application/json: + schema: + type: boolean + example: not-a-boolean + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + responses: + '200': + description: Item + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + description: Description beside a reference. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: '#/components/schemas/Item' + - type: 'null' diff --git a/hack/api-docs/testdata/invalid-info.yaml b/hack/api-docs/testdata/invalid-info.yaml new file mode 100644 index 000000000000..3280debcaee6 --- /dev/null +++ b/hack/api-docs/testdata/invalid-info.yaml @@ -0,0 +1,100 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + description: Component capability fixture. +servers: +- url: https://api.example.test/v1 +security: +- bearerAuth: [] +tags: +- name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: ./schemas/event.yaml + /search: + parameters: + - name: limit + in: query + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + parameters: + - name: limit + in: query + schema: + type: integer + example: 0 + responses: + '200': + description: Search result + content: + application/json: + schema: true + /public: + get: + operationId: getPublic + tags: + - operations + summary: Public result + security: [] + responses: + '200': + description: A flag + content: + application/json: + schema: + type: boolean + example: false + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + responses: + '200': + description: Item + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + description: Description beside a reference. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: '#/components/schemas/Item' + - type: 'null' diff --git a/hack/api-docs/testdata/invalid-item-type.yaml b/hack/api-docs/testdata/invalid-item-type.yaml new file mode 100644 index 000000000000..524584656267 --- /dev/null +++ b/hack/api-docs/testdata/invalid-item-type.yaml @@ -0,0 +1,101 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + version: '1' + description: Component capability fixture. +servers: +- url: https://api.example.test/v1 +security: +- bearerAuth: [] +tags: +- name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + type: definitely-invalid + /search: + parameters: + - name: limit + in: query + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + parameters: + - name: limit + in: query + schema: + type: integer + example: 0 + responses: + '200': + description: Search result + content: + application/json: + schema: true + /public: + get: + operationId: getPublic + tags: + - operations + summary: Public result + security: [] + responses: + '200': + description: A flag + content: + application/json: + schema: + type: boolean + example: false + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + responses: + '200': + description: Item + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + description: Description beside a reference. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: '#/components/schemas/Item' + - type: 'null' diff --git a/hack/api-docs/testdata/invalid-stream-ref.yaml b/hack/api-docs/testdata/invalid-stream-ref.yaml new file mode 100644 index 000000000000..7d4edd2052bb --- /dev/null +++ b/hack/api-docs/testdata/invalid-stream-ref.yaml @@ -0,0 +1,101 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + version: '1' + description: Component capability fixture. +servers: +- url: https://api.example.test/v1 +security: +- bearerAuth: [] +tags: +- name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: ./schemas/missing.yaml + /search: + parameters: + - name: limit + in: query + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + parameters: + - name: limit + in: query + schema: + type: integer + example: 0 + responses: + '200': + description: Search result + content: + application/json: + schema: true + /public: + get: + operationId: getPublic + tags: + - operations + summary: Public result + security: [] + responses: + '200': + description: A flag + content: + application/json: + schema: + type: boolean + example: false + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + responses: + '200': + description: Item + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + description: Description beside a reference. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: '#/components/schemas/Item' + - type: 'null' diff --git a/hack/api-docs/testdata/profile-missing-id.yaml b/hack/api-docs/testdata/profile-missing-id.yaml new file mode 100644 index 000000000000..99fba5ceccbd --- /dev/null +++ b/hack/api-docs/testdata/profile-missing-id.yaml @@ -0,0 +1,100 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + version: '1' + description: Component capability fixture. +servers: +- url: https://api.example.test/v1 +security: +- bearerAuth: [] +tags: +- name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: ./schemas/event.yaml + /search: + parameters: + - name: limit + in: query + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + parameters: + - name: limit + in: query + schema: + type: integer + example: 0 + responses: + '200': + description: Search result + content: + application/json: + schema: true + /public: + get: + tags: + - operations + summary: Public result + security: [] + responses: + '200': + description: A flag + content: + application/json: + schema: + type: boolean + example: false + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + responses: + '200': + description: Item + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + description: Description beside a reference. +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: '#/components/schemas/Item' + - type: 'null' diff --git a/hack/api-docs/testdata/schemas/event.yaml b/hack/api-docs/testdata/schemas/event.yaml new file mode 100644 index 000000000000..b158529a5656 --- /dev/null +++ b/hack/api-docs/testdata/schemas/event.yaml @@ -0,0 +1,9 @@ +type: object +required: +- event +- data +properties: + event: + type: string + data: + type: string diff --git a/hack/api-docs/testdata/valid.yaml b/hack/api-docs/testdata/valid.yaml new file mode 100644 index 000000000000..3ec382de30c6 --- /dev/null +++ b/hack/api-docs/testdata/valid.yaml @@ -0,0 +1,110 @@ +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Docker documentation tooling fixture + version: "1" + description: Component capability fixture. +servers: + - url: https://api.example.test/v1 +security: + - bearerAuth: [] +tags: + - name: operations + summary: Operations + kind: nav + description: Fixture operations. +paths: + /events: + get: + operationId: listEvents + tags: + - operations + summary: List events + description: List events. + responses: + "200": + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: ./schemas/event.yaml + example: { event: message, data: hello } + /search: + parameters: + - name: limit + in: query + description: Maximum number of results. + schema: + type: integer + example: 10 + servers: + - url: https://search.example.test + query: + operationId: querySearch + tags: + - operations + summary: Search + description: Search. + parameters: + - name: limit + in: query + description: Maximum number of results. + schema: + type: integer + example: 0 + responses: + "200": + description: Search result + content: + application/json: + schema: true + example: 0 + /public: + get: + operationId: getPublic + tags: + - operations + summary: Public result + description: Public result. + security: [] + responses: + "200": + description: A flag + content: + application/json: + schema: + type: boolean + example: false + /items: + get: + operationId: getItems + tags: + - operations + summary: Get items + description: Get items. + responses: + "200": + description: Item + content: + application/json: + schema: + $ref: "#/components/schemas/Item" + description: Description beside a reference. + example: { id: 1, next: { id: 2, next: null } } +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + Item: + type: object + required: + - id + properties: + id: + type: integer + next: + anyOf: + - $ref: "#/components/schemas/Item" + - type: "null" diff --git a/hack/api-docs/validation/dialects/lock.json b/hack/api-docs/validation/dialects/lock.json new file mode 100644 index 000000000000..7fb2c1b1cdad --- /dev/null +++ b/hack/api-docs/validation/dialects/lock.json @@ -0,0 +1,20 @@ +[ + { + "file": "oas-dialect.json", + "uri": "https://spec.openapis.org/oas/3.1/dialect/base", + "resolvedURL": "https://spec.openapis.org/oas/3.1/dialect/base", + "sha256": "8a0e89e365dadbebce2921ce6244340c1090e9d544c60d977e9ad6b97a61227b" + }, + { + "file": "oas-vocabulary.json", + "uri": "https://spec.openapis.org/oas/3.1/meta/base", + "resolvedURL": "https://spec.openapis.org/oas/3.1/meta/base", + "sha256": "267a88226e64e96dfc8c89dbd7e863160c84715e0fb893ca1d9fbf9f830f1f54" + }, + { + "file": "oas-document.json", + "uri": "https://spec.openapis.org/oas/3.2/schema/2025-09-17", + "resolvedURL": "https://spec.openapis.org/oas/3.2/schema/2025-09-17", + "sha256": "0c9d74bf25f9b9388b2d81e421ef60fdefa9feffa94898dadfc501b342b3bfcc" + } +] diff --git a/hack/api-docs/validation/dialects/oas-dialect.json b/hack/api-docs/validation/dialects/oas-dialect.json new file mode 100644 index 000000000000..eae8386e8ad3 --- /dev/null +++ b/hack/api-docs/validation/dialects/oas-dialect.json @@ -0,0 +1,25 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/dialect/base", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "title": "OpenAPI 3.1 Schema Object Dialect", + "description": "A JSON Schema dialect describing schemas found in OpenAPI documents", + + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true, + "https://spec.openapis.org/oas/3.1/vocab/base": false + }, + + "$dynamicAnchor": "meta", + + "allOf": [ + { "$ref": "https://json-schema.org/draft/2020-12/schema" }, + { "$ref": "https://spec.openapis.org/oas/3.1/meta/base" } + ] +} diff --git a/hack/api-docs/validation/dialects/oas-document.json b/hack/api-docs/validation/dialects/oas-document.json new file mode 100644 index 000000000000..9453eeb20bec --- /dev/null +++ b/hack/api-docs/validation/dialects/oas-document.json @@ -0,0 +1,1666 @@ +{ + "$id": "https://spec.openapis.org/oas/3.2/schema/2025-09-17", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI v3.2.x Documents without Schema Object validation", + "type": "object", + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.2\\.\\d+(-.+)?$" + }, + "$self": { + "type": "string", + "format": "uri-reference", + "$comment": "MUST NOT contain a fragment", + "pattern": "^[^#]*$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "format": "uri-reference", + "default": "https://spec.openapis.org/oas/3.2/dialect/2025-09-17" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [ + { + "url": "/" + } + ] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "components": { + "$ref": "#/$defs/components" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/tag" + } + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "openapi", + "info" + ], + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "components" + ] + }, + { + "required": [ + "webhooks" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/oas/v3.2#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri-reference" + }, + "contact": { + "$ref": "#/$defs/contact" + }, + "license": { + "$ref": "#/$defs/license" + }, + "version": { + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "contact": { + "$comment": "https://spec.openapis.org/oas/v3.2#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.2#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "name" + ], + "dependentSchemas": { + "identifier": { + "not": { + "required": [ + "url" + ] + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.2#server-object", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server-variable" + } + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server-variable": { + "$comment": "https://spec.openapis.org/oas/v3.2#server-variable-object", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "default" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "components": { + "$comment": "https://spec.openapis.org/oas/v3.2#components-object", + "type": "object", + "properties": { + "schemas": { + "type": "object", + "additionalProperties": { + "$dynamicRef": "#meta" + } + }, + "responses": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/response-or-reference" + } + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + }, + "requestBodies": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/request-body-or-reference" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "securitySchemes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/security-scheme-or-reference" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "pathItems": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "mediaTypes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type-or-reference" + } + } + }, + "patternProperties": { + "^(?:schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems|mediaTypes)$": { + "$comment": "Enumerating all of the property names in the regex above is necessary for unevaluatedProperties to work as expected", + "propertyNames": { + "pattern": "^[a-zA-Z0-9._-]+$" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "paths": { + "$comment": "https://spec.openapis.org/oas/v3.2#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.2#path-item-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "$ref": "#/$defs/parameters" + }, + "additionalOperations": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/operation" + }, + "propertyNames": { + "$comment": "RFC9110 restricts methods to \"1*tchar\" in ABNF", + "pattern": "^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$", + "not": { + "enum": [ + "GET", + "PUT", + "POST", + "DELETE", + "OPTIONS", + "HEAD", + "PATCH", + "TRACE", + "QUERY" + ] + } + } + }, + "get": { + "$ref": "#/$defs/operation" + }, + "put": { + "$ref": "#/$defs/operation" + }, + "post": { + "$ref": "#/$defs/operation" + }, + "delete": { + "$ref": "#/$defs/operation" + }, + "options": { + "$ref": "#/$defs/operation" + }, + "head": { + "$ref": "#/$defs/operation" + }, + "patch": { + "$ref": "#/$defs/operation" + }, + "trace": { + "$ref": "#/$defs/operation" + }, + "query": { + "$ref": "#/$defs/operation" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.2#operation-object", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/parameters" + }, + "requestBody": { + "$ref": "#/$defs/request-body-or-reference" + }, + "responses": { + "$ref": "#/$defs/responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "external-documentation": { + "$comment": "https://spec.openapis.org/oas/v3.2#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + }, + "not": { + "allOf": [ + { + "contains": { + "type": "object", + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + } + }, + { + "contains": { + "type": "object", + "properties": { + "in": { + "const": "querystring" + } + }, + "required": [ + "in" + ] + } + } + ] + }, + "contains": { + "type": "object", + "properties": { + "in": { + "const": "querystring" + } + }, + "required": [ + "in" + ] + }, + "minContains": 0, + "maxContains": 1 + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.2#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "querystring", + "header", + "path", + "cookie" + ] + }, + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": [ + "name", + "in" + ], + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + }, + { + "if": { + "properties": { + "in": { + "const": "query" + } + } + }, + "then": { + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + } + }, + { + "if": { + "properties": { + "in": { + "const": "querystring" + } + } + }, + "then": { + "required": [ + "content" + ] + } + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-path" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-header" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-query" + }, + { + "$ref": "#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-cookie" + }, + { + "$ref": "#/$defs/styles-for-form" + } + ], + "$defs": { + "styles-for-path": { + "if": { + "properties": { + "in": { + "const": "path" + } + } + }, + "then": { + "properties": { + "style": { + "default": "simple", + "enum": [ + "matrix", + "label", + "simple" + ] + }, + "required": { + "const": true + } + }, + "required": [ + "required" + ] + } + }, + "styles-for-header": { + "if": { + "properties": { + "in": { + "const": "header" + } + } + }, + "then": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + } + } + } + }, + "styles-for-query": { + "if": { + "properties": { + "in": { + "const": "query" + } + } + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + } + } + } + }, + "styles-for-cookie": { + "if": { + "properties": { + "in": { + "const": "cookie" + } + } + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "cookie" + ] + } + } + } + } + } + } + }, + "unevaluatedProperties": false + }, + "parameter-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/parameter" + } + }, + "request-body": { + "$comment": "https://spec.openapis.org/oas/v3.2#request-body-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "content": { + "$ref": "#/$defs/content" + }, + "required": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "content" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "request-body-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/request-body" + } + }, + "content": { + "$comment": "https://spec.openapis.org/oas/v3.2#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type-or-reference" + }, + "propertyNames": { + "format": "media-range" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.2#media-type-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "itemSchema": { + "$dynamicRef": "#meta" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/$defs/encoding" + } + }, + "itemEncoding": { + "$ref": "#/$defs/encoding" + } + }, + "dependentSchemas": { + "encoding": { + "properties": { + "prefixEncoding": false, + "itemEncoding": false + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + } + ], + "unevaluatedProperties": false + }, + "media-type-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/media-type" + } + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.2#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string", + "format": "media-range" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "type": "boolean" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/$defs/encoding" + } + }, + "itemEncoding": { + "$ref": "#/$defs/encoding" + } + }, + "dependentSchemas": { + "encoding": { + "properties": { + "prefixEncoding": false, + "itemEncoding": false + } + }, + "style": { + "properties": { + "allowReserved": { + "default": false + } + } + }, + "explode": { + "properties": { + "style": { + "default": "form" + }, + "allowReserved": { + "default": false + } + } + }, + "allowReserved": { + "properties": { + "style": { + "default": "form" + } + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/styles-for-form" + } + ], + "unevaluatedProperties": false + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.2#responses-object", + "type": "object", + "properties": { + "default": { + "$ref": "#/$defs/response-or-reference" + } + }, + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": { + "$ref": "#/$defs/response-or-reference" + } + }, + "minProperties": 1, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "if": { + "$comment": "either default, or at least one response code property must exist", + "patternProperties": { + "^[1-5](?:[0-9]{2}|XX)$": false + } + }, + "then": { + "required": [ + "default" + ] + } + }, + "response": { + "$comment": "https://spec.openapis.org/oas/v3.2#response-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "content": { + "$ref": "#/$defs/content" + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "response-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/response" + } + }, + "callbacks": { + "$comment": "https://spec.openapis.org/oas/v3.2#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.2#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dataValue": true, + "serializedValue": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string", + "format": "uri-reference" + } + }, + "allOf": [ + { + "not": { + "required": [ + "value", + "externalValue" + ] + } + }, + { + "not": { + "required": [ + "value", + "dataValue" + ] + } + }, + { + "not": { + "required": [ + "value", + "serializedValue" + ] + } + }, + { + "not": { + "required": [ + "serializedValue", + "externalValue" + ] + } + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "example-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/example" + } + }, + "link": { + "$comment": "https://spec.openapis.org/oas/v3.2#link-object", + "type": "object", + "properties": { + "operationRef": { + "type": "string", + "format": "uri-reference" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/map-of-strings" + }, + "requestBody": true, + "description": { + "type": "string" + }, + "server": { + "$ref": "#/$defs/server" + } + }, + "oneOf": [ + { + "required": [ + "operationRef" + ] + }, + { + "required": [ + "operationId" + ] + } + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "link-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/link" + } + }, + "header": { + "$comment": "https://spec.openapis.org/oas/v3.2#header-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$dynamicRef": "#meta" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + }, + "explode": { + "default": false, + "type": "boolean" + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/examples" + }, + { + "$ref": "#/$defs/specification-extensions" + } + ], + "unevaluatedProperties": false + }, + "header-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/header" + } + }, + "tag": { + "$comment": "https://spec.openapis.org/oas/v3.2#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "parent": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.2#reference-object", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "schema": { + "$comment": "https://spec.openapis.org/oas/v3.2#schema-object", + "$dynamicAnchor": "meta", + "type": [ + "object", + "boolean" + ] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.2#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": [ + "apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect" + ] + }, + "description": { + "type": "string" + }, + "deprecated": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "type" + ], + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-apikey" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http-bearer" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oauth2" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oidc" + } + ], + "unevaluatedProperties": false, + "$defs": { + "type-apikey": { + "if": { + "properties": { + "type": { + "const": "apiKey" + } + } + }, + "then": { + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "cookie" + ] + } + }, + "required": [ + "name", + "in" + ] + } + }, + "type-http": { + "if": { + "properties": { + "type": { + "const": "http" + } + } + }, + "then": { + "properties": { + "scheme": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + } + }, + "type-http-bearer": { + "if": { + "properties": { + "type": { + "const": "http" + }, + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + }, + "required": [ + "type", + "scheme" + ] + }, + "then": { + "properties": { + "bearerFormat": { + "type": "string" + } + } + } + }, + "type-oauth2": { + "if": { + "properties": { + "type": { + "const": "oauth2" + } + } + }, + "then": { + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + }, + "oauth2MetadataUrl": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "flows" + ] + } + }, + "type-oidc": { + "if": { + "properties": { + "type": { + "const": "openIdConnect" + } + } + }, + "then": { + "properties": { + "openIdConnectUrl": { + "type": "string", + "format": "uri-reference" + } + }, + "required": [ + "openIdConnectUrl" + ] + } + } + } + }, + "security-scheme-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/security-scheme" + } + }, + "oauth-flows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/$defs/oauth-flows/$defs/implicit" + }, + "password": { + "$ref": "#/$defs/oauth-flows/$defs/password" + }, + "clientCredentials": { + "$ref": "#/$defs/oauth-flows/$defs/client-credentials" + }, + "authorizationCode": { + "$ref": "#/$defs/oauth-flows/$defs/authorization-code" + }, + "deviceAuthorization": { + "$ref": "#/$defs/oauth-flows/$defs/device-authorization" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "client-credentials": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "authorization-code": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "device-authorization": { + "type": "object", + "properties": { + "deviceAuthorizationUrl": { + "type": "string", + "format": "uri-reference" + }, + "tokenUrl": { + "type": "string", + "format": "uri-reference" + }, + "refreshUrl": { + "type": "string", + "format": "uri-reference" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "deviceAuthorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.2#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.2#specification-extensions", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + }, + "not": { + "required": [ + "example", + "examples" + ] + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "styles-for-form": { + "if": { + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } +} diff --git a/hack/api-docs/validation/dialects/oas-vocabulary.json b/hack/api-docs/validation/dialects/oas-vocabulary.json new file mode 100644 index 000000000000..a7a59f1c7d13 --- /dev/null +++ b/hack/api-docs/validation/dialects/oas-vocabulary.json @@ -0,0 +1,87 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/meta/base", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "title": "OAS Base vocabulary", + "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect", + + "$vocabulary": { + "https://spec.openapis.org/oas/3.1/vocab/base": true + }, + + "$dynamicAnchor": "meta", + + "type": ["object", "boolean"], + "properties": { + "example": true, + "discriminator": { "$ref": "#/$defs/discriminator" }, + "externalDocs": { "$ref": "#/$defs/external-docs" }, + "xml": { "$ref": "#/$defs/xml" } + }, + + "$defs": { + "extensible": { + "patternProperties": { + "^x-": true + } + }, + + "discriminator": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "propertyName": { + "type": "string" + }, + "mapping": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["propertyName"], + "unevaluatedProperties": false + }, + + "external-docs": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "type": "string" + } + }, + "required": ["url"], + "unevaluatedProperties": false + }, + + "xml": { + "$ref": "#/$defs/extensible", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string", + "format": "uri" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean" + }, + "wrapped": { + "type": "boolean" + } + }, + "unevaluatedProperties": false + } + } +} diff --git a/hack/api-docs/validation/rules.yaml b/hack/api-docs/validation/rules.yaml new file mode 100644 index 000000000000..e3fdaa5d5a43 --- /dev/null +++ b/hack/api-docs/validation/rules.yaml @@ -0,0 +1,24 @@ +# Structural and dialect validation run against locked OAI schemas in the Go command. +# Vacuum supplies independently named Docker policy rules with source locations. +rules: + docker-operation-id: + description: Every operation needs a stable operation ID. + severity: error + given: $.paths[*][get,put,post,delete,options,head,patch,trace,query] + then: + field: operationId + function: truthy + docker-info-description: + description: Every API needs an overview. + severity: error + given: $.info + then: + field: description + function: truthy + docker-info-version: + description: Every API needs a version identity. + severity: error + given: $.info + then: + field: version + function: truthy diff --git a/hack/api-docs/verify-output.mjs b/hack/api-docs/verify-output.mjs new file mode 100644 index 000000000000..33001b850d7d --- /dev/null +++ b/hack/api-docs/verify-output.mjs @@ -0,0 +1,158 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +const root = path.resolve(import.meta.dirname, "../.."); +const base = path.resolve(process.argv[2] || "public"); +const data = JSON.parse( + fs.readFileSync(path.join(root, "tmp/api-reference/data/api-reference.json")), +); +const decode = (s) => + s + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); +// Hugo's production minifier removes optional attribute quotes. +function attributes(html, name) { + const pattern = new RegExp( + `\\s${name}=(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, + "g", + ); + return [...html.matchAll(pattern)].map((m) => decode(m[1] ?? m[2] ?? m[3])); +} +let count = 0; +const problems = []; +function check(url) { + const htmlPath = path.join(base, url, "index.html"); + const mdPath = path.join(base, url.replace(/\/$/, "")) + ".md"; + if (!fs.existsSync(htmlPath) || !fs.existsSync(mdPath)) { + problems.push(`Missing HTML/Markdown: ${url}`); + return ["", ""]; + } + const html = fs.readFileSync(htmlPath, "utf8"), + md = fs.readFileSync(mdPath, "utf8"); + count++; + return [decode(html), md]; +} +check("/reference/api/"); +for (const api of data.apis) { + check(api.url); + const publishedSource = fs.readFileSync(path.join(base, api.sourceURL)); + if (!publishedSource.equals(fs.readFileSync(path.join(root, api.source)))) + problems.push(`Published specification differs from source: ${api.id}`); + + for (const op of api.operations) { + const [html, md] = check(op.url); + for (const variant of op.variants) { + if ( + !attributes(html, "data-api-variant").includes(variant.pointer) || + !md.includes(`### ${variant.direction} ${variant.status}`) + ) + problems.push(`Missing variant: ${op.url} ${variant.pointer}`); + for (const example of variant.examples) { + if ( + !html.includes(example.text.trim()) || + !md.includes("```" + example.language + "\n" + example.text) + ) + problems.push(`Example mismatch: ${op.url} ${variant.pointer}`); + } + } + for (const p of op.parameters) + if ( + !attributes(html, "data-api-parameter").includes(p.name) || + !md.includes(`### ${p.name}`) + ) + problems.push(`Missing parameter: ${op.url} ${p.name}`); + if (!html.includes(op.path) || !md.includes(op.method + " " + op.path)) + problems.push(`Missing operation signature: ${op.url}`); + if (!md.includes(op.curl)) + problems.push(`Request example mismatch: ${op.url}`); + } + for (const schema of api.schemas) { + const [html, md] = check(schema.url); + if ( + ![...md.matchAll(/```json\n([\s\S]*?)\n```/g)].some((m) => { + try { + return isDeepStrictEqual(JSON.parse(m[1]), schema.schema); + } catch { + return false; + } + }) + ) + problems.push(`Incomplete Markdown schema: ${schema.url}`); + if (!html.includes(schema.name)) + problems.push(`Missing schema heading: ${schema.url}`); + } +} +// Check API reference links in every generated preview page, including copied specification prose. +function files(dir) { + return fs + .readdirSync(dir, { withFileTypes: true }) + .flatMap((e) => + e.isDirectory() + ? files(path.join(dir, e.name)) + : [path.join(dir, e.name)], + ); +} +for (const file of [...files(path.join(base, "reference/api"))].filter((p) => + p.endsWith(".html"), +)) { + const html = fs.readFileSync(file, "utf8"); + if (!html.includes("data-api-view=")) continue; + for (const href of attributes(html, "href")) { + let url; + try { + url = new URL(href, "http://localhost:1314/" + path.relative(base, file)); + } catch { + continue; + } + if ( + url.hostname !== "localhost" || + !url.pathname.startsWith("/reference/api/") + ) + continue; + const target = path.join( + base, + decodeURIComponent(url.pathname), + url.pathname.endsWith("/") ? "index.html" : "", + ); + if (!fs.existsSync(target)) + problems.push(`Broken API link: ${path.relative(base, file)} -> ${href}`); + } +} +const governance = fs.readFileSync( + path.join(base, "reference/api/ai-governance/index.html"), + "utf8", +); +if (governance.includes("data-api-view=")) + problems.push("Governance renderer changed"); +if (fs.existsSync(path.join(base, "api-prototype"))) + problems.push("Unexpected prototype routes"); +// Historical Engine pages must keep the original renderer at their existing URLs. +for (let minor = 40; minor <= 56; minor++) { + const url = `/reference/api/engine/version/v1.${minor}/`; + const html = fs.readFileSync(path.join(base, url, "index.html"), "utf8"); + if (!html.includes(" n + fs.statSync(p).size, + 0, +); +fs.writeFileSync( + path.join(root, "tmp/api-reference/reports/output.json"), + JSON.stringify( + { htmlMarkdownPagePairs: count, referenceBytes: bytes, result: "pass" }, + null, + 2, + ) + "\n", +); +console.log( + `Verified ${count} HTML/Markdown page pairs and API reference links.`, +); diff --git a/hack/flatten-and-resolve.js b/hack/flatten-and-resolve.js index 04532d343eb5..b79ca5e21208 100755 --- a/hack/flatten-and-resolve.js +++ b/hack/flatten-and-resolve.js @@ -1,236 +1,99 @@ #!/usr/bin/env node /** - * Flattens markdown directory structure and resolves all links to absolute paths. - * - * This script: - * 1. Moves index.md files up one level (ai/model-runner/index.md -> ai/model-runner.md) - * 2. Fixes _index.md and index.md references in links - * 3. Strips /manuals/ prefix from paths (Hugo config removes this) - * 4. Resolves all relative links to absolute HTML paths for RAG ingestion - * - * Usage: node flatten-and-resolve.js [public-dir] + * Resolve Markdown links relative to each original file, then flatten index.md. + * Only link destinations change; examples and other text stay byte-identical. + * Usage: node hack/flatten-and-resolve.js [public-dir] */ - -const fs = require('fs'); -const path = require('path'); - -const PUBLIC_DIR = path.resolve(process.argv[2] || 'public'); - -if (!fs.existsSync(PUBLIC_DIR)) { - console.error(`Error: Directory ${PUBLIC_DIR} does not exist`); - process.exit(1); +const fs = require("node:fs"); +const path = require("node:path"); + +const publicDir = path.resolve(process.argv[2] || "public"); + +function markdownFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() + ? markdownFiles(file) + : file.endsWith(".md") + ? [file] + : []; + }); } -/** - * Recursively find all files matching a predicate - */ -function findFiles(dir, predicate) { - const results = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...findFiles(fullPath, predicate)); - } else if (entry.isFile() && predicate(entry.name)) { - results.push(fullPath); - } - } - - return results; +function resolveLink(link, file) { + // External URLs, protocol-relative URLs, and same-page anchors are complete. + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(link)) return link; + const [, pathname, suffix] = link.match(/^([^?#]*)(.*)$/s); + if (!pathname) return link; + let url = pathname.startsWith("/") + ? pathname + : "/" + + path + .relative(publicDir, path.resolve(path.dirname(file), pathname)) + .split(path.sep) + .join("/"); + url = url.replace(/^\/manuals\//, "/"); + url = url.replace(/\/_?index\.md$/, "/").replace(/\.md$/, "/"); + return url + suffix; } -/** - * Step 1: Flatten index.md files - * Move path/to/section/index.md -> path/to/section.md - * Before moving, rewrite sibling links (e.g., "get-started.md" -> "section/get-started.md") - */ -function flattenIndexFiles() { - const indexFiles = findFiles(PUBLIC_DIR, name => name === 'index.md'); - let count = 0; - - for (const file of indexFiles) { - // Skip root index.md - if (file === path.join(PUBLIC_DIR, 'index.md')) { - continue; - } - - const dir = path.dirname(file); - const dirname = path.basename(dir); - - // Read content and fix sibling links - let content = fs.readFileSync(file, 'utf8'); - - // Rewrite relative links that don't start with /, ../, or http - // These are sibling files that will become children after flattening - content = content.replace( - /\[([^\]]+)\]\(([a-zA-Z0-9][^):]*)\)/g, - (match, text, link) => { - // Skip if it's a URL or starts with special chars - if (link.startsWith('http://') || link.startsWith('https://') || - link.startsWith('#')) { - return match; - } - return `[${text}](${dirname}/${link})`; - } - ); - - // Also fix reference-style links - content = content.replace( - /^\[([^\]]+)\]:\s+([a-zA-Z0-9][^: ]*\.md)$/gm, - (match, ref, link) => `[${ref}]: ${dirname}/${link}` - ); - - fs.writeFileSync(file, content, 'utf8'); - - // Move file up one level - const parentDir = path.dirname(dir); - const newPath = path.join(parentDir, `${dirname}.md`); - fs.renameSync(file, newPath); - - count++; +// Micromark provides exact destination spans, excluding labels, titles, and code. +// Apply edits backwards so offsets stay valid and formatting stays untouched. +function rewriteLinks(content, events, file) { + const destinations = events + .filter( + ([event, token]) => + event === "enter" && + ["resourceDestinationString", "definitionDestinationString"].includes( + token.type, + ), + ) + .map(([, token]) => token) + .sort((a, b) => b.start.offset - a.start.offset); + for (const token of destinations) { + const start = token.start.offset; + const end = token.end.offset; + const destination = content.slice(start, end); + content = + content.slice(0, start) + + resolveLink(destination, file) + + content.slice(end); } - - console.log(`Flattened ${count} index.md files`); - return count; + return content; } -/** - * Step 2: Fix _index.md and index.md references in all files - * Also strip /manuals/ prefix from paths - */ -function fixIndexReferences() { - const mdFiles = findFiles(PUBLIC_DIR, name => name.endsWith('.md')); - let count = 0; - - for (const file of mdFiles) { - const dir = path.dirname(file); - const dirname = path.basename(dir); - const parentDir = path.dirname(dir); - const parentDirname = path.basename(parentDir); - - let content = fs.readFileSync(file, 'utf8'); - const original = content; - - // Fix path/_index.md or path/index.md -> path.md - content = content.replace(/([a-zA-Z0-9_/-]+)\/_?index\.md/g, '$1.md'); - - // Fix bare _index.md or index.md -> ../dirname.md - content = content.replace(/_?index\.md/g, `../${dirname}.md`); - - // Fix ../_index.md that became ...md -> ../../parentdirname.md - if (parentDir !== PUBLIC_DIR) { - content = content.replace(/\.\.\.md/g, `../../${parentDirname}.md`); - } - - // Strip /manuals/ prefix (both /manuals/ and manuals/) - content = content.replace(/\/?manuals\//g, '/'); - +async function main() { + const { parse, preprocess, postprocess } = await import("micromark"); + const files = markdownFiles(publicDir); + let rewritten = 0; + let flattened = 0; + for (const file of files) { + const original = fs.readFileSync(file, "utf8"); + const events = postprocess( + parse() + .document() + .write(preprocess()(original, "utf8", true)), + ); + const content = rewriteLinks(original, events, file); if (content !== original) { - fs.writeFileSync(file, content, 'utf8'); - count++; + fs.writeFileSync(file, content); + rewritten++; } - } - - console.log(`Fixed _index.md references in ${count} files`); - return count; -} - -/** - * Step 3: Resolve all relative links to absolute HTML paths - */ -function resolveLinks() { - const mdFiles = findFiles(PUBLIC_DIR, name => name.endsWith('.md')); - let count = 0; - - for (const file of mdFiles) { - let content = fs.readFileSync(file, 'utf8'); - const original = content; - - // Process inline links: [text](path) - content = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, link) => { - const resolved = resolveLinkPath(link, file); - return `[${text}](${resolved})`; - }); - - // Process reference-style links: [ref]: path - content = content.replace(/^\[([^\]]+)\]:\s+(.+)$/gm, (match, ref, link) => { - const resolved = resolveLinkPath(link, file); - return `[${ref}]: ${resolved}`; - }); - - if (content !== original) { - fs.writeFileSync(file, content, 'utf8'); - count++; + if ( + path.basename(file) === "index.md" && + file !== path.join(publicDir, "index.md") + ) { + fs.renameSync(file, path.dirname(file) + ".md"); + flattened++; } } - - console.log(`Resolved links in ${count} files`); - return count; -} - -/** - * Resolve a link path to absolute HTML format - */ -function resolveLinkPath(linkPath, currentFile) { - // Skip external URLs and mailto - if (linkPath.startsWith('http://') || linkPath.startsWith('https://') || - linkPath.startsWith('mailto:')) { - return linkPath; - } - - // Skip same-page anchors - if (linkPath.startsWith('#')) { - return linkPath; - } - - // Split path and anchor - const hashIndex = linkPath.indexOf('#'); - const pathPart = hashIndex >= 0 ? linkPath.substring(0, hashIndex) : linkPath; - const anchorPart = hashIndex >= 0 ? linkPath.substring(hashIndex) : ''; - - if (!pathPart) { - // Just an anchor - return linkPath; - } - - // Handle absolute paths - just convert to HTML format - if (pathPart.startsWith('/')) { - return toHtmlPath(pathPart) + anchorPart; - } - - // Resolve relative path to absolute - const currentDir = path.dirname(currentFile); - const absolutePath = path.resolve(currentDir, pathPart); - const relativePath = path.relative(PUBLIC_DIR, absolutePath); - - // Convert to URL path (forward slashes) - const urlPath = '/' + relativePath.split(path.sep).join('/'); - - return toHtmlPath(urlPath) + anchorPart; -} - -/** - * Convert a path to HTML format (strip .md, add trailing /) - */ -function toHtmlPath(urlPath) { - if (urlPath.endsWith('.md')) { - return urlPath.slice(0, -3) + '/'; - } - return urlPath; + console.log( + `Markdown: rewrote links in ${rewritten} files; flattened ${flattened} index.md files`, + ); } -// Main execution -console.log('Starting markdown flattening and link resolution...'); -console.log(''); - -const flattenCount = flattenIndexFiles(); -const fixCount = fixIndexReferences(); -const resolveCount = resolveLinks(); - -console.log(''); -console.log('Done!'); -console.log(`- Flattened: ${flattenCount} files`); -console.log(`- Fixed references: ${fixCount} files`); -console.log(`- Resolved links: ${resolveCount} files`); +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/hack/test/flatten-and-resolve.mjs b/hack/test/flatten-and-resolve.mjs new file mode 100644 index 000000000000..697287c35350 --- /dev/null +++ b/hack/test/flatten-and-resolve.mjs @@ -0,0 +1,119 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const script = path.resolve(import.meta.dirname, "../flatten-and-resolve.js"); + +test("flattens all pages while preserving examples and resolving documentation links", () => { + const site = fs.mkdtempSync(path.join(os.tmpdir(), "docs-flatten-")); + const generated = + "# API\n\n" + + "[Guide](/reference/api/registry/auth/)\n\n" + + '```json\n{"path":"/manuals/example/index.md","text":"[sample](file.md)"}\n```\n'; + const write = (name, value) => { + const file = path.join(site, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, value); + }; + try { + write("reference/api/index.md", generated); + write("reference/api/registry/latest/index.md", generated); + write( + "reference/api/registry/auth/index.md", + "# Guide\n\n[Example](example.md)\n", + ); + write("reference/api/registry/auth/example.md", "# Example\n"); + for (let run = 0; run < 2; run++) { + execFileSync(process.execPath, [script, site], { stdio: "pipe" }); + assert.equal( + fs.readFileSync(path.join(site, "reference/api.md"), "utf8"), + generated, + ); + assert.equal( + fs.readFileSync( + path.join(site, "reference/api/registry/latest.md"), + "utf8", + ), + generated, + ); + assert.ok( + !fs.existsSync( + path.join(site, "reference/api/registry/latest/index.md"), + ), + ); + assert.match( + fs.readFileSync( + path.join(site, "reference/api/registry/auth.md"), + "utf8", + ), + /\[Example\]\(\/reference\/api\/registry\/auth\/example\/\)/, + ); + } + } finally { + fs.rmSync(site, { recursive: true, force: true }); + } +}); + +test("rewrites only destinations and preserves Markdown syntax", () => { + const site = fs.mkdtempSync(path.join(os.tmpdir(), "docs-links-")); + const cases = [ + ["[Guide](/manuals/engine/_index.md#install)", "[Guide](/engine/#install)"], + ["[Parent](../index.md)", "[Parent](/guide/)"], + ["[Root](/index.md)", "[Root](/)"], + [ + '[Title]( "A title")', + '[Title]( "A title")', + ], + [ + "[Nested **label**](file(test).md?q=1#part)", + "[Nested **label**](/guide/section/file(test)/?q=1#part)", + ], + ["[`odd ] bracket`](file.md)", "[`odd ] bracket`](/guide/section/file/)"], + [ + "![Image [label]](picture.png)", + "![Image [label]](/guide/section/picture.png)", + ], + [ + '[ref]: /manuals/engine/index.md "Title"\n\n[Reference][ref]', + '[ref]: /engine/ "Title"\n\n[Reference][ref]', + ], + ["> - [Guide](file.md)", "> - [Guide](/guide/section/file/)"], + [ + "[External](https://example.com/manuals/index.md)", + "[External](https://example.com/manuals/index.md)", + ], + [ + "[CDN](//example.com/manuals/index.md)", + "[CDN](//example.com/manuals/index.md)", + ], + ["[Mail](mailto:docs@example.com)", "[Mail](mailto:docs@example.com)"], + ["[Anchor](#example)", "[Anchor](#example)"], + ["`[Example](/manuals/index.md)`", "`[Example](/manuals/index.md)`"], + [" [Example](/manuals/index.md)", " [Example](/manuals/index.md)"], + [ + '~~~json\n{"file":"/manuals/index.md"}\n~~~', + '~~~json\n{"file":"/manuals/index.md"}\n~~~', + ], + [ + "A literal /manuals/example/index.md filename.", + "A literal /manuals/example/index.md filename.", + ], + ]; + try { + fs.mkdirSync(path.join(site, "guide/section"), { recursive: true }); + fs.writeFileSync( + path.join(site, "guide/section/index.md"), + cases.map(([before]) => before).join("\n\n"), + ); + execFileSync(process.execPath, [script, site]); + assert.equal( + fs.readFileSync(path.join(site, "guide/section.md"), "utf8"), + cases.map(([, after]) => after).join("\n\n"), + ); + } finally { + fs.rmSync(site, { recursive: true, force: true }); + } +}); diff --git a/hugo.yaml b/hugo.yaml index adef38e31097..831f2a6b2b9c 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -327,6 +327,10 @@ module: extended: false min: "0.156.0" mounts: + - source: data + target: data + - source: tmp/api-reference/data + target: data # Mount the assets directory so it doesn't get overwritten - source: assets target: assets diff --git a/layouts/_partials/api-reference/description.html b/layouts/_partials/api-reference/description.html new file mode 100644 index 000000000000..1616cc83a7f5 --- /dev/null +++ b/layouts/_partials/api-reference/description.html @@ -0,0 +1,13 @@ +{{- $text := .text | default "" -}} +{{- $api := .api -}} +{{- range $api.operations -}} + {{- $text = replace $text (printf "(#operation/%s)" .id) (printf "(%s)" .url) -}} + {{- $text = replaceRE (printf "\\(#tag/[^)]+/operation/%s\\)" .id) (printf "(%s)" .url) $text -}} +{{- end -}} +{{- range $api.schemas -}} + {{- $text = replace $text (printf "(#schema-%s)" .name) (printf "(%s)" .url) -}} +{{- end -}} +{{- range $api.tags -}} + {{- $text = replace $text (printf "(#tag/%s)" .name) (printf "(%s#tag-%s)" $api.url (.name | urlize)) -}} +{{- end -}} +{{- return $text -}} diff --git a/layouts/_partials/api-reference/manual-link.html b/layouts/_partials/api-reference/manual-link.html new file mode 100644 index 000000000000..0b92028d75f7 --- /dev/null +++ b/layouts/_partials/api-reference/manual-link.html @@ -0,0 +1,11 @@ +{{ $data := index hugo.Data "api-reference" }} +{{ range $data.apis }} + {{ $manual := site.GetPage .manual }} + {{ if and $manual (eq $manual.RelPermalink $.RelPermalink) }} +

+ Explore the {{ .title }} API {{ .version }} → +

+ {{ end }} +{{ end }} diff --git a/layouts/_partials/api-reference/navigation.html b/layouts/_partials/api-reference/navigation.html new file mode 100644 index 000000000000..3a814260e04e --- /dev/null +++ b/layouts/_partials/api-reference/navigation.html @@ -0,0 +1,36 @@ +{{ $data := index hugo.Data "api-reference" }} + diff --git a/layouts/_partials/api-reference/overview-tags.html b/layouts/_partials/api-reference/overview-tags.html new file mode 100644 index 000000000000..796138cc1c00 --- /dev/null +++ b/layouts/_partials/api-reference/overview-tags.html @@ -0,0 +1,11 @@ +{{- /* Navigation placeholders and schema-only links are covered by the indexes. */ -}} +{{- $visible := slice -}} +{{- range .tags -}} + {{- $description := strings.TrimSpace (.description | default "") -}} + {{- $placeholder := or (eq $description (printf "%s reference." .summary)) (eq $description (printf "%s operations." .name)) -}} + {{- $schemaLink := findRE `^\[[^\]]+\]\(#schema-[^)]+\)$` $description -}} + {{- if and $description (not $placeholder) (not $schemaLink) -}} + {{- $visible = $visible | append . -}} + {{- end -}} +{{- end -}} +{{- return $visible -}} diff --git a/layouts/_partials/api-reference/path.html b/layouts/_partials/api-reference/path.html new file mode 100644 index 000000000000..cb8045e25430 --- /dev/null +++ b/layouts/_partials/api-reference/path.html @@ -0,0 +1,4 @@ +{{- /* Break long paths between segments without changing their copied text. */ -}} +{{- range $i, $segment := split . "/" -}} + {{- if $i -}}/{{- end -}}{{- $segment -}} +{{- end -}} diff --git a/layouts/_partials/api-reference/schema.html b/layouts/_partials/api-reference/schema.html new file mode 100644 index 000000000000..d044f93dafab --- /dev/null +++ b/layouts/_partials/api-reference/schema.html @@ -0,0 +1,85 @@ +{{ $s := .schema }}{{ $api := .api }}{{ $depth := .depth }} +{{ if reflect.IsMap $s }} + {{ with index $s "$ref" }} + {{ $url := index $api.schemaURLs . }} +

+ Schema: + {{ if $url }} + {{ strings.TrimPrefix "#/components/schemas/" . }} + {{ else }} + {{ . }} + {{ end }} +

+ {{ end }} + {{ with $s.type }} +

+ Type: + {{ if reflect.IsSlice . }} + {{ delimit . " | " }} + {{ else }} + {{ . }} + {{ end }} +

+ {{ end }} + {{ with $s.description }} +
{{ . | markdownify }}
+ {{ end }} + {{ if lt $depth 3 }} + {{ range $name,$v := $s.properties }} +
+ + {{ $name }}{{ if in $s.required $name }} + · required + {{ end }}{{ if $v.readOnly }} + · response only + {{ end }}{{ if $v.writeOnly }}· request only{{ end }} + + {{ partial "api-reference/schema.html" (dict "schema" $v "api" $api "depth" (add $depth 1)) }} +
+ {{ end }} + {{ range $keyword := slice "allOf" "oneOf" "anyOf" }} + {{ with index $s $keyword }} +
+ + {{ if eq $keyword "allOf" }} + All constraints apply + {{ else if eq $keyword "oneOf" }} + Exactly one alternative must match + {{ else }} + One or more alternatives must match + {{ end }} + + {{ range . }} + {{ partial "api-reference/schema.html" (dict "schema" . "api" $api "depth" (add $depth 1)) }} + {{ end }} +
+ {{ end }} + {{ end }} + {{ if isset $s "items" }} +
+ Array item + {{ partial "api-reference/schema.html" (dict "schema" $s.items "api" $api "depth" (add $depth 1)) }} +
+ {{ end }} + {{ end }} + {{ if or (ge $depth 3) (not (index $s "$ref")) (gt (len $s) 1) }} +
+ All schema constraints and annotations +
{{ $s | jsonify (dict "indent" "  ") }}
+
+ {{ end }} +{{ else }} +

+ {{ if eq $s false }} + No value satisfies this schema. + {{ else if eq $s true }} + Any value satisfies this schema. + {{ else }} + No schema is declared. + {{ end }} +

+{{ end }} diff --git a/layouts/_partials/content-default.html b/layouts/_partials/content-default.html index 9d7c32067066..73580a30d58c 100644 --- a/layouts/_partials/content-default.html +++ b/layouts/_partials/content-default.html @@ -12,6 +12,7 @@

{{ .Title | safeHTML }}

{{ partialCached "pagemeta.html" . . }}
+{{ partial "api-reference/manual-link.html" . }} {{ .Content }} {{- if and (eq .Type "guides") (not .IsSection) (ne .CurrentSection .FirstSection) -}} {{- with .PrevInSection -}} diff --git a/layouts/_partials/sidebar/sections.html b/layouts/_partials/sidebar/sections.html index ab1650ff1a86..0bd06b5dcd8a 100644 --- a/layouts/_partials/sidebar/sections.html +++ b/layouts/_partials/sidebar/sections.html @@ -30,7 +30,7 @@ {{- end }} {{- $ungrouped := where $pages "Params.sidebar.group" "==" nil }} {{- range $ungrouped }} - {{- if .IsSection }} + {{- if and .IsSection (not .Params.apiID) }} {{- template "renderList" (dict "section" . "revealHidden" $revealHidden) }} {{- else }} {{- template "renderSingle" . }} @@ -43,7 +43,7 @@ {{ . }} {{- range where $pages "Params.sidebar.group" . }} - {{- if .IsSection }} + {{- if and .IsSection (not .Params.apiID) }} {{- template "renderList" (dict "section" . "revealHidden" $revealHidden) }} {{- else }} {{- template "renderSingle" . }} diff --git a/layouts/api-docs.html b/layouts/api-docs.html new file mode 100644 index 000000000000..6303b9fe73e6 --- /dev/null +++ b/layouts/api-docs.html @@ -0,0 +1,369 @@ +{{ define "head-extra" }} + {{ $style := resources.Get "api-reference/reference.css" | fingerprint }} + + {{ $script := resources.Get "api-reference/reference.js" | fingerprint }} + +{{ end }} +{{ define "left" }} + {{ partial "sidebar/mainnav.html" . }} + {{ if eq .Params.view "catalog" }} + {{ partial "sidebar/sections.html" . }} + {{ else }} + {{ partial "api-reference/navigation.html" . }} + {{ end }} +{{ end }} +{{ define "main" }} +
+ {{ $data := index hugo.Data "api-reference" }} + {{ $api := dict }}{{ range $data.apis }} + {{ if eq .id $.Params.apiID }}{{ $api = . }}{{ end }} + {{ end }} + {{ if eq .Params.view "catalog" }} +

Developer reference

+

Docker APIs

+

+ Build with Docker, from your local daemon to hosted services. +

+

+ Choose an API to find connection guidance, operations, and data models. +

+ + {{ else }} + +
+ Product manualDownload OpenAPI specificationMarkdown +
+ + {{ if eq .Params.view "overview" }} +

+ {{ if eq $api.connection "unix" }} + User-operated API + {{ else }} + Hosted API + {{ end }} +

+

{{ $api.title }} API

+

+ API {{ $api.version }} · {{ len $api.operations }} operations · + {{ len $api.schemas }} named schemas +

+
+

Overview

+
+ {{ partial "api-reference/description.html" (dict "text" $api.description "api" $api) | markdownify }} +
+
+
+

+ {{ if eq $api.connection "unix" }} + Connecting to + {{ $api.title }} + {{ else }} + Connecting to the {{ $api.title }} API + {{ end }} +

+ {{ range $name, $scheme := $api.securitySchemes }} + {{ with $scheme.description }} +

{{ index $scheme "x-displayName" | default $name }}

+
{{ . | markdownify }}
+ {{ end }} + {{ end }} + {{ if eq $api.connection "unix" }} +
curl --unix-socket /var/run/docker.sock http://localhost/v{{ $api.version }}/version
+ {{ else }} + {{ range $api.servers }}{{ .url }}{{ end }} + {{ end }}{{ range $api.guides }} + {{ $guide := site.GetPage (index (split . "#") 0) }} +

{{ $guide.Title }}

+ {{ end }} +
+
+ {{ $overviewTags := partial "api-reference/overview-tags.html" $api }} + {{ range $api.tags }} + {{ if not (in $overviewTags .) }} + + {{ end }} + {{ end }} + {{ range $overviewTags }} +
+

{{ .summary }}

+
+ {{ partial "api-reference/description.html" (dict "text" .description "api" $api) | markdownify }} +
+
+ {{ end }} +
+

Operations

+ +
+ {{ range $api.operations }} + {{ .method }}{{ partial "api-reference/path.html" .path }}{{ .summary }} + {{ end }} +
+

Schemas

+ + {{ else if eq .Params.view "operation" }} + {{ range $api.operations }} + {{ if eq .id $.Params.operationID }} +

{{ .summary }}

+
+ {{ .method }}{{ partial "api-reference/path.html" .path }}{{ if .deprecated }} + Deprecated + {{ end }} +
+
+
+
+ {{ partial "api-reference/description.html" (dict "text" .description "api" $api) | markdownify }} +
+

Connection and access

+

+ API connection and authentication guidance +

+ {{ if and (eq $api.product "engine") (where .parameters "name" "X-Registry-Auth") }} +

+ X-Registry-Auth delegates registry credentials + and does not authenticate the daemon caller. +

+ {{ end }} + {{ range .servers }} +

+ {{ .url }}{{ with .description }} + — + {{ . }} + {{ end }} +

+ {{ end }} + {{ if not .security }} +

+ No HTTP authentication requirement is declared for this + operation. Transport access controls can still apply. +

+ {{ else }} +

+ Use one of these alternatives. Requirements within an + alternative apply together. +

+
    + {{ range .security }} +
  • + {{ $first := true }}{{ range $name, $scopes := . }} + {{ if not $first }} + AND + {{ end }}{{ $first = false }}{{ $name }}{{ with $scopes }} + ({{ delimit . ", " }}) + {{ end }} + {{ end }}{{ if $first }}Anonymous access{{ end }} +
  • + {{ end }} +
+ {{ end }} +

Parameters

+ {{ if not .parameters }} +

No parameters are declared.

+ {{ end }} + {{ range .parameters }} +
+

+ {{ .name }} + {{ .in }} + {{ if .required }} + Required + {{ end }} +

+
+ {{ partial "api-reference/description.html" (dict "text" .description "api" $api) | markdownify }} +
+ {{ if isset . "schema" }} + {{ partial "api-reference/schema.html" (dict "schema" .schema "api" $api "depth" 0) }} + {{ end }}{{ with .content }} +
{{ . | jsonify (dict "indent" "  ") }}
+ {{ end }}{{ with .style }} +

Serialization: {{ . }}

+ {{ end }} +
+ {{ end }} +

Request and responses

+ + {{ range $i, $v := .variants }} +
+

+ {{ $v.direction | humanize }} + {{ with $v.status }} + {{ . }} + {{ end }} +

+
+ {{ partial "api-reference/description.html" (dict "text" $v.description "api" $api) | markdownify }} +
+ {{ if $v.media }} +

{{ $v.media }}

+ {{ else }} +

No response content is declared.

+ {{ end }} + {{ with $v.headers }} +

Headers

+ {{ range $name,$header := . }} +
+ {{ $name }} +

{{ $header.description }}

+
{{ $header | jsonify (dict "indent" "  ") }}
+
+ {{ end }} + {{ end }} + {{ if isset $v "schema" }} + {{ partial "api-reference/schema.html" (dict "schema" $v.schema "api" $api "depth" 0) }} + {{ end }} + {{ if isset $v "itemSchema" }} +

Stream item

+ {{ partial "api-reference/schema.html" (dict "schema" $v.itemSchema "api" $api "depth" 0) }} + {{ end }} + {{ if $v.examples }} +
+ {{ if gt (len $v.examples) 1 }} + + {{ end }}{{ range $j,$ex := $v.examples }} +
+

{{ $ex.name }}

+
{{ $ex.text }}
+
+ {{ end }} +
+ {{ end }} +
+ {{ end }} +

Referenced schemas

+ {{ range .references }} + {{ if .url }} +

{{ .ref }}

+ {{ end }} + {{ end }} +
+ Complete operation contract +
{{ .raw | jsonify (dict "indent" "  ") }}
+
+
+ +
+ {{ end }} + {{ end }} + {{ else if eq .Params.view "schema" }} + {{ range $api.schemas }} + {{ if eq .name $.Params.schemaName }} +

Schema

+

{{ .name }}

+ {{ partial "api-reference/schema.html" (dict "schema" .schema "api" $api "depth" 0) }} + {{ end }} + {{ end }} + {{ end }} + {{ end }} +
+{{ end }} diff --git a/layouts/api-docs.markdown.md b/layouts/api-docs.markdown.md new file mode 100644 index 000000000000..5f6e679b27cd --- /dev/null +++ b/layouts/api-docs.markdown.md @@ -0,0 +1,155 @@ +{{- $data := index hugo.Data "api-reference" -}} +{{- $api := dict -}}{{- range $data.apis -}}{{- if eq .id $.Params.apiID -}}{{- $api = . -}}{{- end -}}{{- end -}} +# {{ .Title }} + +{{ if eq .Params.view "catalog" }} +Choose a Docker HTTP API: +{{ range $data.apis }} +- [{{ .title }} API {{ .version }}]({{ .url }}): {{ len .operations }} operations; {{ .connection }} connection +{{ end }} +{{ range $data.legacyAPIs }} +- [{{ .title }}]({{ .url }}): {{ .description }} +{{ end }} +{{ else }} +[API catalog](/reference/api/) · [{{ $api.title }} overview]({{ $api.url }}) · [Product manual]({{ ref . $api.manual }}) · [OpenAPI specification]({{ $api.sourceURL }}) + +API version: {{ $api.version }} + +{{ if eq .Params.view "overview" }} +## Overview + +{{ partial "api-reference/description.html" (dict "text" $api.description "api" $api) }} +## {{ if eq $api.connection "unix" }}Connecting to {{ $api.title }}{{ else }}Connecting to the {{ $api.title }} API{{ end }} + +{{ range $name, $scheme := $api.securitySchemes }}{{ with $scheme.description }} +### {{ index $scheme "x-displayName" | default $name }} + +{{ . }} +{{ end }}{{ end }} +{{ range $api.servers }} +Server: `{{ .url }}` +{{ end }} +{{ if eq $api.connection "unix" }} +```console +curl --unix-socket /var/run/docker.sock http://localhost/v{{ $api.version }}/version +``` +{{ end }} +{{ range $api.guides }} +{{ $guide := site.GetPage (index (split . "#") 0) }} +- [{{ $guide.Title }}]({{ ref $ . }}) +{{ end }} + +{{ range (partial "api-reference/overview-tags.html" $api) }} +## {{ .summary }} + +{{ partial "api-reference/description.html" (dict "text" .description "api" $api) }} +{{ end }} +## Operations +{{ range $api.operations }} +- [{{ .method }} {{ .path }}]({{ .url }}): {{ .summary }} +{{ end }} +## Schemas +{{ range $api.schemas }} +- [{{ .name }}]({{ .url }}) +{{ end }} +{{ else if eq .Params.view "operation" }} +{{ range $api.operations }}{{ if eq .id $.Params.operationID }} +`{{ .method }} {{ .path }}` + +{{ partial "api-reference/description.html" (dict "text" .description "api" $api) }} +{{ if .deprecated }} +Deprecated operation. +{{ end }} +## Connection and access + +[API connection and authentication guidance]({{ $api.url }}#authentication) +{{ if and (eq $api.product "engine") (where .parameters "name" "X-Registry-Auth") }} +`X-Registry-Auth` delegates registry credentials and does not authenticate the daemon caller. +{{ end }} +{{ range .servers }} +Server: `{{ .url }}` +{{ end }} +Effective security: alternatives are OR; schemes within an alternative are AND. An empty array declares no HTTP authentication requirement. + +```json +{{ .security | jsonify (dict "indent" " ") }} +``` +## Example request + +Replace placeholders and provide the required credentials or request body. + +```console +{{ .curl }} +``` +{{ range .curlNotes }} +{{ . }} +{{ end }} +## Parameters +{{ range .parameters }} +### {{ .name }} + +Location: {{ .in }}. Required: {{ if .required }}yes{{ else }}no{{ end }}. + +{{ partial "api-reference/description.html" (dict "text" .description "api" $api) }} + +```json +{{ . | jsonify (dict "indent" " ") }} +``` +{{ end }} +## Request and responses +{{ range .variants }} +### {{ .direction }} {{ .status }} {{ .media }} + +{{ partial "api-reference/description.html" (dict "text" .description "api" $api) }} +{{ if not .media }} +No response content is declared. +{{ end }} +{{ if isset . "schema" }} +Schema: + +```json +{{ .schema | jsonify (dict "indent" " ") }} +``` +{{ end }} +{{ if isset . "itemSchema" }} +Stream item schema: + +```json +{{ .itemSchema | jsonify (dict "indent" " ") }} +``` +{{ end }} +{{ with .headers }} +Headers: + +```json +{{ . | jsonify (dict "indent" " ") }} +``` +{{ end }} +{{ range .examples }} +{{ .name }}: +```{{ .language }} +{{ .text }} +``` +{{ end }} +{{ end }} +## Complete operation contract + +```json +{{ .raw | jsonify (dict "indent" " ") }} +``` + +## Referenced schemas +{{ range .references }} +- {{ if .url }}[{{ .ref }}]({{ .url }}){{ else }}`{{ .ref }}`{{ end }} +{{ end }} +{{ end }}{{ end }} +{{ else if eq .Params.view "schema" }} +{{ range $api.schemas }}{{ if eq .name $.Params.schemaName }} +Schema constraints and annotations: + +```json +{{ .schema | jsonify (dict "indent" " ") }} +``` +{{ end }}{{ end }} +{{ end }} +{{ end }} diff --git a/layouts/baseof.html b/layouts/baseof.html index 823d4a087912..87dfff2839fd 100644 --- a/layouts/baseof.html +++ b/layouts/baseof.html @@ -2,6 +2,7 @@ {{ partial "head.html" . }} + {{ block "head-extra" . }}{{ end }} =8" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -1728,6 +1751,34 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -1736,6 +1787,14 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -1748,6 +1807,18 @@ "node": ">=0.10" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dompurify": { "version": "3.4.6", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.6.tgz", @@ -2241,6 +2312,406 @@ "node": ">= 20" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2263,6 +2734,11 @@ "node": ">=4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", diff --git a/package.json b/package.json index 8cc4272493ba..bb4de3c29c77 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "highlight.js": "11.11.1", "marked": "17.0.4", "mermaid": "11.15.0", + "micromark": "4.0.2", "tailwindcss": "4.2.1" }, "devDependencies": {