1
0
mirror of https://github.com/docker/docs.git synced 2026-10-23 18:09:02 +03:00

docs: unify Hub, DVP, and Registry API references (#26048)

@netlify /reference/api/

[Preview the API
reference](https://deploy-preview-26048--docsdocker.netlify.app/reference/api/)

Adds a shared API reference experience for Hub, DVP, and Registry: a
catalog, operation navigation, linked schemas, request and response
examples, and matching HTML and Markdown output. A validation and
generation pipeline prepares the reference data for Hugo and runs as
part of Docker and Netlify builds.

Migrates the three specifications to OpenAPI 3.2, correcting response
shapes, nullable fields, and authentication and token examples. The Hub
migration also corrects SCIM contracts and CSV exports; DVP gains
complete report response schemas and examples. Registry keeps its
documented Hub-specific subset, with authentication and media
negotiation expressed through OpenAPI security and content fields.

Validation is strict for both checks and generation, with no exception
baseline or bypass flag. The build uses the migrated sources directly,
without migration scripts or records.

Page and specification URLs remain stable, including the DVP alias.
Operation and schema anchors change. Engine and Governance retain their
existing references.

Validated locally with Go tests, strict validation of all three
specifications (zero diagnostics), scoped formatting and lint, and a
full Hugo build checking all 176 HTML/Markdown page pairs, example
parity, reference links, and byte-identical published specifications. A
browser check confirmed raw CSV rendering and the generated request
header. No live API requests were made.
This commit is contained in:
David Karlsson
2026-09-17 16:56:32 +02:00
committed by GitHub
parent e18cacc49b
commit 9b1f7de05f
67 changed files with 8318 additions and 1195 deletions

View File

@@ -7,11 +7,9 @@ IgnoreAltEmpty: true
IgnoreEmptyHref: true
IgnoreDirectoryMissingTrailingSlash: true
IgnoreURLs:
- "^/reference/api/hub/.*$"
- "^/reference/api/engine/v.+/#.*$"
- "^/reference/api/registry/.*$"
- "^/pagefind/.*$"
- "^/reference/api/engine/v.+/#.*$"
- "^/pagefind/.*$"
IgnoreDirs:
- "registry/configuration"
- "compose/compose-file" # temporarily ignore until upstream is fixed
- "registry/configuration"
- "compose/compose-file" # temporarily ignore until upstream is fixed
CacheExpires: "6h"

View File

@@ -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

View File

@@ -424,3 +424,4 @@ superset
tokenization
WebGL
Xubuntu
Netlify

View File

@@ -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;
}
}

View File

@@ -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,
);
});
}),
);

View File

@@ -11,3 +11,16 @@ services:
- action: sync
path: .
target: /project
ignore:
- hack/api-docs/
- content/reference/api/hub/latest.yaml
- content/reference/api/dvp/latest.yaml
- content/reference/api/registry/latest.yaml
- action: rebuild
path: hack/api-docs
- action: rebuild
path: content/reference/api/hub/latest.yaml
- action: rebuild
path: content/reference/api/dvp/latest.yaml
- action: rebuild
path: content/reference/api/registry/latest.yaml

View File

@@ -38,7 +38,7 @@ organization.
{{< /tab >}}
{{< tab name="API">}}
To view activity logs using the Docker Hub API, use the [Audit logs endpoints](https://docs.docker.com/reference/api/hub/latest/#tag/audit-logs).
To view activity logs using the Docker Hub API, use the [Audit logs endpoints](/reference/api/hub/latest/#tag-audit-logs).
{{< /tab >}}
{{< /tabs >}}
@@ -49,7 +49,7 @@ To view activity logs using the Docker Hub API, use the [Audit logs endpoints](h
>
> Docker Home retains activity logs for 30 days. To retrieve
activities beyond 30 days, you must use the
[Docker Hub API](https://docs.docker.com/reference/api/hub/latest/#tag/audit-logs).
[Docker Hub API](/reference/api/hub/latest/#tag-audit-logs).
By default, the **Activity** tab displays all recorded events within
the last 30 days. To narrow your view, use the calendar to select a specific

View File

@@ -258,7 +258,7 @@ After you invite and manage members, explore these related topics:
[roles-permissions]: /manuals/security/roles-and-permissions/_index.md
[licenses-and-invites]: /manuals/accounts/organization/manage/manage-licenses.md#licenses-and-invites
[bulk-invites]: /reference/api/hub/latest/#tag/invites/paths/~1v2~1invites~1bulk/post
[bulk-invites]: /reference/api/hub/latest/operations/postV2InvitesBulk/
[docker-pricing]: https://www.docker.com/pricing?ref=Docs&refAction=DocsAdminMembers
[scim-role-mapping]: /manuals/security/provisioning/scim/_index.md
[scim-provisioning]: /manuals/security/provisioning/scim/_index.md

View File

@@ -207,7 +207,7 @@ The updated [Docker Subscription Service Agreement](https://www.docker.com/legal
When managing the content of your repositories, you can now filter the results based on the currentness of the tags and more easily identify your untagged images.
For Docker Hub API documentation, see [Docker Hub API Reference](/reference/api/hub/latest.md#operation/GetNamespacesRepositoriesImages).
For Docker Hub API documentation, see [Docker Hub API Reference](/reference/api/hub/latest/).
## 2021-04-13

View File

@@ -181,7 +181,7 @@ invitations are not supported for this role.
To invite distributor members:
1. Use the [authentication
API](https://docs.docker.com/reference/api/hub/latest/#tag/authentication-api/operation/AuthCreateAccessToken)
API](/reference/api/hub/latest/operations/AuthCreateAccessToken/)
to generate a bearer token for your Docker Hub account. This token authorizes
the API requests you use to send invites. Replace `myusername` and
`dckr_pat_...` with your Docker ID and a [personal access
@@ -213,7 +213,7 @@ To invite distributor members:
{{< tab name="API" >}}
Use the [teams
API](https://docs.docker.com/reference/api/hub/latest/#tag/groups/paths/~1v2~1orgs~1%7Borg_name%7D~1groups/post):
API](/reference/api/hub/latest/operations/postV2OrgsByOrgNameGroups/):
```console
$ curl -s -X POST "https://hub.docker.com/v2/orgs/example-org/groups" \
@@ -245,7 +245,7 @@ To invite distributor members:
{{< tab name="API" >}}
Use the [repository teams
API](https://docs.docker.com/reference/api/hub/latest/#tag/repositories/operation/CreateRepositoryGroup),
API](/reference/api/hub/latest/operations/CreateRepositoryGroup/),
passing the team's `id` from the previous step as `group_id`:
```console
@@ -259,7 +259,7 @@ To invite distributor members:
{{< /tabs >}}
4. Use the [bulk create invites
endpoint](https://docs.docker.com/reference/api/hub/latest/#tag/invites/paths/~1v2~1invites~1bulk/post)
endpoint](/reference/api/hub/latest/operations/postV2InvitesBulk/)
to send email invites. In the request body, set `role` to
`distributor_member`, specify the `team`, and list the invitees' email
addresses:

View File

@@ -28,7 +28,7 @@ The following objects are shown in the diagram.
Use the following steps to delete one or more items via the graphical user
interface. To delete in bulk, see the [deletion API
endpoint](/reference/api/registry/latest/#tag/delete).
endpoint](/reference/api/registry/latest/#tag-delete).
> [!NOTE]
> Images associated with [immutable tags](./immutable-tags.md) can't be

View File

@@ -192,11 +192,11 @@ allowed`, regardless of the token's scopes. Use the linked replacement
endpoints instead:
- `GET /v2/repositories/{namespace}/{repository}` — use
[Get repository](/reference/api/hub/latest/#tag/repositories/operation/GetRepository).
[Get repository](/reference/api/hub/latest/operations/GetRepository/).
- `GET /v2/repositories/{namespace}` — use
[List repositories](/reference/api/hub/latest/#tag/repositories/operation/listNamespaceRepositories).
[List repositories](/reference/api/hub/latest/operations/listNamespaceRepositories/).
- `GET /v2/users/{username}/repositories` — use
[List repositories](/reference/api/hub/latest/#tag/repositories/operation/listNamespaceRepositories).
[List repositories](/reference/api/hub/latest/operations/listNamespaceRepositories/).
## Organization access token best practices

View File

@@ -27,7 +27,7 @@ Docker supports the following SCIM attributes:
| `active` | Indicates if a user is enabled or disabled, set to "false" to de-provision a user |
For additional details about supported attributes and SCIM, see
[Docker Hub API SCIM reference](/reference/api/hub/latest.md#tag/scim).
[Docker Hub API SCIM reference](/reference/api/hub/latest/#tag-scim).
> [!IMPORTANT]
>

View File

@@ -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" "weight" 1 "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 -}}

View File

@@ -1,6 +0,0 @@
---
title: API reference
build:
render: never
---

View File

@@ -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/
---

View File

@@ -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: |
<SchemaDefinition schemaRef="#/components/schemas/ResponseDataFile" />
[ResponseDataFile](#schema-ResponseDataFile)
summary: ResponseDataFile
kind: info
- name: yearModel
x-displayName: Year Data Model
description: |
<SchemaDefinition schemaRef="#/components/schemas/YearModel" />
[YearModel](#schema-YearModel)
summary: Year Data Model
kind: info
- name: monthModel
x-displayName: Month Data Model
description: |
<SchemaDefinition schemaRef="#/components/schemas/MonthModel" />
[MonthModel](#schema-MonthModel)
summary: Month Data Model
kind: info
- name: weekModel
x-displayName: Week Data Model
description: |
<SchemaDefinition schemaRef="#/components/schemas/WeekModel" />
[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,142 +594,306 @@ 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: |
JWT Bearer Authentication is required to access the Docker DVP Data API.
This authentication documentation is duplicated from the [Hub API Authentication docs](https://docs.docker.com/reference/api/hub/latest/#tag/authentication)
This authentication documentation is duplicated from the [Hub API Authentication docs](https://docs.docker.com/reference/api/hub/latest/#authentication)
x-displayName: Docker Hub Authentication
jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base
x-features-openapi:
schemaDefinitionsTagName: Schemas

View File

@@ -5,5 +5,5 @@ title: Latest
params:
sidebar:
goto: /reference/api/engine/latest/
weight: 20
weight: 1
---

View File

@@ -1,5 +1,6 @@
---
title: API reference by version
weight: 4
build:
render: never
sidebar:

View File

@@ -19,12 +19,12 @@ issues for Docker Service APIs.
### Updates
- Document that [Update org member (role)](/reference/api/hub/latest/#tag/orgs)
- Document that [Update org member (role)](/reference/api/hub/latest/#tag-orgs)
accepts a custom role name in `role`.
- Document the `role` field on
[Update some details for an organization group](/reference/api/hub/latest/#tag/groups).
[Update some details for an organization group](/reference/api/hub/latest/#tag-groups).
- Add `Editor` to `org_member.role` on
[organization members](/reference/api/hub/latest/#tag/orgs).
[organization members](/reference/api/hub/latest/#tag-orgs).
---
@@ -33,9 +33,9 @@ issues for Docker Service APIs.
### Updates
- Document organization access token (OAT) support for
[repository management](/reference/api/hub/latest/#tag/repositories)
[repository management](/reference/api/hub/latest/#tag-repositories)
endpoints, the scope-dependent behavior of
[List repositories](/reference/api/hub/latest/#tag/repositories/operation/listNamespaceRepositories),
[List repositories](/reference/api/hub/latest/operations/listNamespaceRepositories/),
and legacy paths that are OAT unsupported.
---
@@ -44,13 +44,13 @@ issues for Docker Service APIs.
### Updates
- Add missing `expires_at` fields on [PAT management](/reference/api/hub/latest/#tag/access-tokens) endpoints.
- Add missing `expires_at` fields on [PAT management](/reference/api/hub/latest/#tag-access-tokens) endpoints.
## 2025-09-25
### Updates
- Fix [Assign repository group](/reference/api/hub/latest/#tag/repositories/operation/CreateRepositoryGroup) endpoints request/response
- Fix [Assign repository group](/reference/api/hub/latest/operations/CreateRepositoryGroup/) endpoints request/response
---
@@ -58,16 +58,16 @@ issues for Docker Service APIs.
### New
- Add [Create repository](/reference/api/hub/latest/#tag/repositories/operation/CreateRepository) endpoints for a given `namespace`.
- Add [Get repository](/reference/api/hub/latest/#tag/repositories/operation/GetRepository) endpoints for a given `namespace`.
- Add [Check repository](/reference/api/hub/latest/#tag/repositories/operation/CheckRepository) endpoints for a given `namespace`.
- Add [Create repository](/reference/api/hub/latest/operations/CreateRepository/) endpoints for a given `namespace`.
- Add [Get repository](/reference/api/hub/latest/operations/GetRepository/) endpoints for a given `namespace`.
- Add [Check repository](/reference/api/hub/latest/operations/CheckRepository/) endpoints for a given `namespace`.
### Deprecations
- [Deprecate POST /v2/repositories](/reference/api/hub/deprecated/#deprecate-legacy-createrepository)
- [Deprecate POST /v2/repositories/{namespace}](/reference/api/hub/deprecated/#deprecate-legacy-createrepository)
- [Deprecate GET /v2/repositories/{namespace}/{repository}](/reference/api/hub/deprecated/#deprecate-legacy-getrepository)
- [Deprecate HEAD /v2/repositories/{namespace}/{repository}](/reference/api/hub/deprecated/#deprecate-legacy-getrepository)
- [Deprecate POST /v2/repositories](/reference/api/hub/deprecated/#deprecate-legacy-createrepository-and-getrepository)
- [Deprecate POST /v2/repositories/{namespace}](/reference/api/hub/deprecated/#deprecate-legacy-createrepository-and-getrepository)
- [Deprecate GET /v2/repositories/{namespace}/{repository}](/reference/api/hub/deprecated/#deprecate-legacy-createrepository-and-getrepository)
- [Deprecate HEAD /v2/repositories/{namespace}/{repository}](/reference/api/hub/deprecated/#deprecate-legacy-createrepository-and-getrepository)
---
@@ -75,8 +75,8 @@ issues for Docker Service APIs.
### New
- Add [Update repository immutable tags settings](/reference/api/hub/latest/#tag/repositories/operation/UpdateRepositoryImmutableTags) endpoints for a given `namespace` and `repository`.
- Add [Verify repository immutable tags](/reference/api/hub/latest/#tag/repositories/operation/VerifyRepositoryImmutableTags) endpoints for a given `namespace` and `repository`.
- Add [Update repository immutable tags settings](/reference/api/hub/latest/operations/UpdateRepositoryImmutableTags/) endpoints for a given `namespace` and `repository`.
- Add [Verify repository immutable tags](/reference/api/hub/latest/operations/VerifyRepositoryImmutableTags/) endpoints for a given `namespace` and `repository`.
---
@@ -84,7 +84,7 @@ issues for Docker Service APIs.
### New
- Add [List repositories](/reference/api/hub/latest/#tag/repositories/operation/listNamespaceRepositories) endpoints for a given `namespace`.
- Add [List repositories](/reference/api/hub/latest/operations/listNamespaceRepositories/) endpoints for a given `namespace`.
### Deprecations
@@ -96,7 +96,7 @@ issues for Docker Service APIs.
### New
- Add [APIs](/reference/api/hub/latest/#tag/org-access-tokens) for organization access token (OATs) management.
- Add [APIs](/reference/api/hub/latest/#tag-org-access-tokens) for organization access token (OATs) management.
---
@@ -104,5 +104,5 @@ issues for Docker Service APIs.
### New
- Add access to [audit logs](/reference/api/hub/latest/#tag/audit-logs) for org
- Add access to [audit logs](/reference/api/hub/latest/#tag-audit-logs) for org
access tokens.

View File

@@ -38,15 +38,15 @@ The endpoint may be removed, disabled, or change behavior in a future release.
### Deprecate legacy CreateRepository and GetRepository
Deprecate undocumented endpoints :
- `POST /v2/repositories` and `POST /v2/repositories/{namespace}` replaced by [Create repository](/reference/api/hub/latest/#tag/repositories/operation/CreateRepository).
- `GET /v2/repositories/{namespace}/{repository}` replaced by [Get repository](/reference/api/hub/latest/#tag/repositories/operation/GetRepository).
- `HEAD /v2/repositories/{namespace}/{repository}` replaced by [Check repository](/reference/api/hub/latest/#tag/repositories/operation/CheckRepository).
- `POST /v2/repositories` and `POST /v2/repositories/{namespace}` replaced by [Create repository](/reference/api/hub/latest/operations/CreateRepository/).
- `GET /v2/repositories/{namespace}/{repository}` replaced by [Get repository](/reference/api/hub/latest/operations/GetRepository/).
- `HEAD /v2/repositories/{namespace}/{repository}` replaced by [Check repository](/reference/api/hub/latest/operations/CheckRepository/).
---
### Deprecate legacy ListNamespaceRepositories
Deprecate undocumented endpoint `GET /v2/repositories/{namespace}` replaced by [List repositories](/reference/api/hub/latest/#tag/repositories/operation/listNamespaceRepositories).
Deprecate undocumented endpoint `GET /v2/repositories/{namespace}` replaced by [List repositories](/reference/api/hub/latest/operations/listNamespaceRepositories/).
---
@@ -72,9 +72,9 @@ If you want to continue using the Docker Hub API in your current applications, u
| **OLD** | **NEW** |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------|
| [/v1/repositories/{name}/tags](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#list-repository-tags) | [/v2/namespaces/{namespace}/repositories/{repository}/tags](/reference/api/hub/latest/#tag/repositories/operation/ListRepositoryTags) |
| [/v1/repositories/{namespace}/{name}/tags](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#list-repository-tags) | [/v2/namespaces/{namespace}/repositories/{repository}/tags](/reference/api/hub/latest.md/#tag/repositories/operation/ListRepositoryTags) |
| [/v1/repositories/{namespace}/{name}/tags](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#get-image-id-for-a-particular-tag) | [/v2/namespaces/{namespace}/repositories/{repository}/tags/{tag}](/reference/api/hub/latest/#tag/repositories/operation/GetRepositoryTag) |
| [/v1/repositories/{namespace}/{name}/tags/{tag_name}](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#get-image-id-for-a-particular-tag) | [/v2/namespaces/{namespace}/repositories/{repository}/tags/{tag}](/reference/api/hub/latest/#tag/repositories/operation/GetRepositoryTag) |
| [/v1/repositories/{name}/tags](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#list-repository-tags) | [/v2/namespaces/{namespace}/repositories/{repository}/tags](/reference/api/hub/latest/operations/ListRepositoryTags/) |
| [/v1/repositories/{namespace}/{name}/tags](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#list-repository-tags) | [/v2/namespaces/{namespace}/repositories/{repository}/tags](/reference/api/hub/latest/operations/ListRepositoryTags/) |
| [/v1/repositories/{namespace}/{name}/tags](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#get-image-id-for-a-particular-tag) | [/v2/namespaces/{namespace}/repositories/{repository}/tags/{tag}](/reference/api/hub/latest/operations/GetRepositoryTag/) |
| [/v1/repositories/{namespace}/{name}/tags/{tag_name}](https://github.com/moby/moby/blob/v1.8.3/docs/reference/api/registry_api.md#get-image-id-for-a-particular-tag) | [/v2/namespaces/{namespace}/repositories/{repository}/tags/{tag}](/reference/api/hub/latest/operations/GetRepositoryTag/) |
---

View File

@@ -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
---

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
---
title: Registry authentication
weight: 10
description: "Specifies the Docker Registry v2 authentication"
keywords: registry, images, tags, repository, distribution, Bearer authentication, advanced
---
@@ -218,4 +219,4 @@ token placed in the HTTP `Authorization` header like so:
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IkJWM0Q6MkFWWjpVQjVaOktJQVA6SU5QTDo1RU42Ok40SjQ6Nk1XTzpEUktFOkJWUUs6M0ZKTDpQT1RMIn0.eyJpc3MiOiJhdXRoLmRvY2tlci5jb20iLCJzdWIiOiJCQ0NZOk9VNlo6UUVKNTpXTjJDOjJBVkM6WTdZRDpBM0xZOjQ1VVc6NE9HRDpLQUxMOkNOSjU6NUlVTCIsImF1ZCI6InJlZ2lzdHJ5LmRvY2tlci5jb20iLCJleHAiOjE0MTUzODczMTUsIm5iZiI6MTQxNTM4NzAxNSwiaWF0IjoxNDE1Mzg3MDE1LCJqdGkiOiJ0WUpDTzFjNmNueXk3a0FuMGM3cktQZ2JWMUgxYkZ3cyIsInNjb3BlIjoiamxoYXduOnJlcG9zaXRvcnk6c2FtYWxiYS9teS1hcHA6cHVzaCxwdWxsIGpsaGF3bjpuYW1lc3BhY2U6c2FtYWxiYTpwdWxsIn0.Y3zZSwaZPqy4y9oRBVRImZyv3m_S9XDHF1tWwN7mL52C_IiA73SJkWVNsvNqpJIn5h7A2F8biv_S2ppQ1lgkbw
```
This is also described in [Section 2.1 of RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage](https://tools.ietf.org/html/rfc6750#section-2.1)
This is also described in [Section 2.1 of RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage](https://tools.ietf.org/html/rfc6750#section-2.1)

View File

@@ -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
---

View File

@@ -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.
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 <<EOF
{
@@ -187,14 +184,14 @@ tags:
-H "Content-Type: application/vnd.docker.distribution.manifest.v2+json" \
-d "$MANIFEST" \
https://registry-1.docker.io/v2/$REPO/manifests/$TAG
echo "Pushed image to $REPO:$TAG"
```
This example pushes a minimal image with no layers. To push a complete image, repeat steps 2–3 for each layer and include the layer digests in the `layers[]` field of the manifest.
summary: Pushing Images
kind: info
- name: delete
x-displayName: Deleting Images
description: |
Deleting an image involves removing its manifest by digest. You must first retrieve the manifest digest, then issue a `DELETE` request using that digest.
@@ -215,36 +212,36 @@ tags:
```bash
#!/bin/bash
USERNAME=yourusername
PASSWORD=dckr_pat
REPO=yourusername/helloworld
TAG=latest
# Step 1: Get a bearer token
TOKEN=$(curl -s -u "$USERNAME:$PASSWORD" \
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:$REPO:pull,push,delete" \
| jq -r .token)
# Step 2 and 3: Get the manifest and extract the digest from response headers
DIGEST=$(curl -sI -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
https://registry-1.docker.io/v2/$REPO/manifests/$TAG \
| grep -i Docker-Content-Digest | tr -d '\r' | awk '{print $2}')
echo "Deleting manifest with digest: $DIGEST"
# Step 4: Delete the manifest by digest
curl -s -X DELETE \
-H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/$REPO/manifests/$DIGEST
echo "Deleted image: $REPO@$DIGEST"
```
This example deletes the manifest for the `latest` tag. To fully delete all references to an image, ensure no other tags or referrers point to the same manifest digest.
summary: Deleting Images
kind: info
paths:
/v2/{name}/manifests/{reference}:
get:
@@ -259,7 +256,13 @@ paths:
The manifest contains metadata about the image, including configuration and layer digests. It is required for pulling images from the registry.
This endpoint requires authentication. Use the `Authorization: Bearer <token>` 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 <token>`).
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 <token>`).
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 <token>
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: "<binary data not shown>"
value: <binary data not shown>
"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: "<binary data not shown>"
value: <binary data not shown>
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: "<binary data not shown>"
value: <binary data not shown>
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: []

87
hack/api-docs/README.md Normal file
View File

@@ -0,0 +1,87 @@
# 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.
With `docker compose watch`, changes to the registered specifications or
`hack/api-docs/` rebuild the server image and regenerate the reference.
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. It also checks local links and fragments from API pages,
including links to `docs.docker.com`. The site `htmltest` checks include migrated
Hub, DVP, and Registry references. `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.

View File

@@ -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;
}

View File

@@ -0,0 +1,128 @@
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/createOrgAccessTokenRequest", `{"label":"CI token"}`, false},
{"/components/schemas/createOrgAccessTokenRequest", `{"label":"CI token","resources":[]}`, false},
{"/components/schemas/createOrgAccessTokenRequest", `{"label":"CI token","resources":[{"type":"TYPE_REPO","path":"myorg/myrepo","scopes":["scope-image-pull"]}]}`, true},
{"/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", "<html>&\n", "<html>&\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)
}
}
}

18
hack/api-docs/go.mod Normal file
View File

@@ -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
)

30
hack/api-docs/go.sum Normal file
View File

@@ -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=

761
hack/api-docs/main.go Normal file
View File

@@ -0,0 +1,761 @@
// 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 {
continue
}
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 {
continue
}
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")
}
for i, requirement := range arr(op["security"]) {
for _, name := range keys(obj(requirement)) {
if _, ok := obj(obj(d.Root["components"])["securitySchemes"])[name]; !ok {
d.issue("security", fmt.Sprintf("%s/security/%d/%s", p, i, esc(name)), "Undefined security scheme: "+name)
}
}
}
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 (d *Document) 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(d.resolve(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
}

354
hack/api-docs/main_test.go Normal file
View File

@@ -0,0 +1,354 @@
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")
}
}
func TestUndefinedSecurityRequirements(t *testing.T) {
for _, inherited := range []bool{false, true} {
d := fixture(t, "valid.yaml")
requirements := []any{Object{"bearerAuth": []any{}}, Object{"missingAuth": []any{}}}
if inherited {
d.Root["security"] = requirements
} else {
obj(obj(obj(d.Root["paths"])["/public"])["get"])["security"] = requirements
}
d.validate("")
found := false
for _, diagnostic := range d.Diagnostics {
if diagnostic.Rule == "security" && strings.Contains(diagnostic.Message, "missingAuth") {
found = true
}
}
if !found {
t.Fatalf("undefined security accepted (inherited=%v)", inherited)
}
}
}
func TestReferencedMediaExample(t *testing.T) {
d := fixture(t, "valid.yaml")
obj(d.Root["components"])["examples"] = Object{"Flag": Object{"value": false}}
media := obj(obj(obj(obj(obj(d.Root["paths"])["/public"])["get"])["responses"])["200"])
v := obj(obj(media["content"])["application/json"])
delete(v, "example")
v["examples"] = Object{"flag": Object{"$ref": "#/components/examples/Flag"}}
d.validate("")
if len(d.Diagnostics) != 0 {
t.Fatalf("referenced example rejected: %v", d.Diagnostics)
}
examples := d.mediaExamples(v)
if len(examples) != 1 || obj(examples[0])["value"] != false {
t.Fatalf("referenced false example lost: %#v", examples)
}
}
func TestExternalDynamicReference(t *testing.T) {
original, err := os.ReadFile("testdata/valid.yaml")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "schemas"), 0755); err != nil {
t.Fatal(err)
}
event, err := os.ReadFile("testdata/schemas/event.yaml")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "schemas/event.yaml"), event, 0644); err != nil {
t.Fatal(err)
}
file := filepath.Join(dir, "api.yaml")
if err := os.WriteFile(file, []byte(strings.Replace(string(original), "$ref: ./schemas/event.yaml", "$dynamicRef: ./schemas/event.yaml", 1)), 0644); err != nil {
t.Fatal(err)
}
d, err := loadDocument(file, "validation/dialects")
if err != nil {
t.Fatal(err)
}
d.validate("")
if len(d.Diagnostics) != 0 {
t.Fatalf("external dynamic reference rejected: %v", d.Diagnostics)
}
}

293
hack/api-docs/model.go Normal file
View File

@@ -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://<SERVER>"
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 := d.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
}

44
hack/api-docs/run.sh Executable file
View File

@@ -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

View File

@@ -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."
}
]
}

View File

@@ -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'

View File

@@ -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'

100
hack/api-docs/testdata/invalid-info.yaml vendored Normal file
View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -0,0 +1,9 @@
type: object
required:
- event
- data
properties:
event:
type: string
data:
type: string

110
hack/api-docs/testdata/valid.yaml vendored Normal file
View File

@@ -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"

View File

@@ -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"
}
]

View File

@@ -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" }
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,176 @@
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("&amp;", "&")
.replaceAll("&#34;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
// 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 local links and fragments in generated API pages, including 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;
const article = html.match(
/<article\b[^>]*class=["']?api-reference[\s\S]*?<\/article>/,
)?.[0];
if (!article) {
problems.push(`Missing API article: ${path.relative(base, file)}`);
continue;
}
for (const href of attributes(article, "href")) {
let url;
try {
url = new URL(href, "http://localhost:1314/" + path.relative(base, file));
} catch {
continue;
}
if (
!["localhost", "docs.docker.com"].includes(url.hostname) ||
!["http:", "https:"].includes(url.protocol)
)
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}`);
} else if (url.hash && target.endsWith(".html")) {
const targetHTML = fs.readFileSync(target, "utf8");
const anchors = [
...attributes(targetHTML, "id"),
...attributes(targetHTML, "name"),
];
if (!anchors.includes(decodeURIComponent(url.hash.slice(1))))
problems.push(
`Broken API anchor: ${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("<redoc") || html.includes("data-api-view="))
problems.push(`Legacy Engine renderer changed: ${url}`);
}
if (problems.length)
throw Error(
problems.slice(0, 25).join("\n") + `\n${problems.length} output failures`,
);
const bytes = files(path.join(base, "reference/api")).reduce(
(n, p) => 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.`,
);

View File

@@ -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("node:fs");
const path = require("node:path");
const fs = require('fs');
const path = require('path');
const publicDir = path.resolve(process.argv[2] || "public");
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);
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 });
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;
}
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);
}
// 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);
}
return results;
return content;
}
/**
* 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})`;
}
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)),
);
// 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++;
}
console.log(`Flattened ${count} index.md files`);
return count;
}
/**
* 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, '/');
const content = rewriteLinks(original, events, file);
if (content !== original) {
fs.writeFileSync(file, content, 'utf8');
count++;
fs.writeFileSync(file, content);
rewritten++;
}
if (
path.basename(file) === "index.md" &&
file !== path.join(publicDir, "index.md")
) {
fs.renameSync(file, path.dirname(file) + ".md");
flattened++;
}
}
console.log(`Fixed _index.md references in ${count} files`);
return count;
console.log(
`Markdown: rewrote links in ${rewritten} files; flattened ${flattened} index.md files`,
);
}
/**
* 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++;
}
}
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;
}
// 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;
});

View File

@@ -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](<file.md> "A title")',
'[Title](</guide/section/file/> "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 });
}
});

View File

@@ -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

View File

@@ -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 -}}

View File

@@ -0,0 +1,11 @@
{{ $data := index hugo.Data "api-reference" }}
{{ range $data.apis }}
{{ $manual := site.GetPage .manual }}
{{ if and $manual (eq $manual.RelPermalink $.RelPermalink) }}
<p
class="my-4 rounded border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900"
>
<a href="{{ .url }}">Explore the {{ .title }} API {{ .version }} →</a>
</p>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,36 @@
{{ $data := index hugo.Data "api-reference" }}
<nav class="api-nav" aria-label="API navigation">
<a class="api-nav-back" href="/reference/api/">← API catalog</a>
{{ range $data.apis }}
{{ if eq .id $.Params.apiID }}
<h2>{{ .title }}</h2>
<p class="api-nav-version">API {{ .version }}</p>
<a
href="{{ .url }}"
{{ if eq $.Params.view "overview" }}
aria-current="page" id="sidebar-current-page"
{{ end }}
>Overview</a
>
{{ range .tags }}
{{ if eq .kind "nav" }}
{{ $tag := .name }}
<h3>{{ .summary }}</h3>
{{ range (index (where $data.apis "id" $.Params.apiID) 0).operations }}
{{ if eq (index .tags 0) $tag }}
<a
class="api-nav-operation"
href="{{ .url }}"
{{ if eq .id $.Params.operationID }}
aria-current="page" id="sidebar-current-page"
{{ end }}
><small data-method="{{ .method }}">{{ .method }}</small
><span>{{ .summary }}</span></a
>
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
</nav>

View File

@@ -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 -}}

View File

@@ -0,0 +1,4 @@
{{- /* Break long paths between segments without changing their copied text. */ -}}
{{- range $i, $segment := split . "/" -}}
{{- if $i -}}<wbr />/{{- end -}}{{- $segment -}}
{{- end -}}

View File

@@ -0,0 +1,85 @@
{{ $s := .schema }}{{ $api := .api }}{{ $depth := .depth }}
{{ if reflect.IsMap $s }}
{{ with index $s "$ref" }}
{{ $url := index $api.schemaURLs . }}
<p class="api-ref">
Schema:
{{ if $url }}
<a href="{{ $url }}"
><code>{{ strings.TrimPrefix "#/components/schemas/" . }}</code></a
>
{{ else }}
<code>{{ . }}</code>
{{ end }}
</p>
{{ end }}
{{ with $s.type }}
<p class="api-type">
Type:
<code
>{{ if reflect.IsSlice . }}
{{ delimit . " | " }}
{{ else }}
{{ . }}
{{ end }}</code
>
</p>
{{ end }}
{{ with $s.description }}
<div class="prose dark:prose-invert">{{ . | markdownify }}</div>
{{ end }}
{{ if lt $depth 3 }}
{{ range $name,$v := $s.properties }}
<details class="api-field" {{ if eq $depth 0 }}open{{ end }}>
<summary>
<code>{{ $name }}</code>{{ if in $s.required $name }}
· required
{{ end }}{{ if $v.readOnly }}
· response only
{{ end }}{{ if $v.writeOnly }}· request only{{ end }}
</summary>
{{ partial "api-reference/schema.html" (dict "schema" $v "api" $api "depth" (add $depth 1)) }}
</details>
{{ end }}
{{ range $keyword := slice "allOf" "oneOf" "anyOf" }}
{{ with index $s $keyword }}
<details class="api-field" open>
<summary>
{{ 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 }}
</summary>
{{ range . }}
{{ partial "api-reference/schema.html" (dict "schema" . "api" $api "depth" (add $depth 1)) }}
{{ end }}
</details>
{{ end }}
{{ end }}
{{ if isset $s "items" }}
<details class="api-field">
<summary>Array item</summary>
{{ partial "api-reference/schema.html" (dict "schema" $s.items "api" $api "depth" (add $depth 1)) }}
</details>
{{ end }}
{{ end }}
{{ if or (ge $depth 3) (not (index $s "$ref")) (gt (len $s) 1) }}
<details class="api-constraints">
<summary>All schema constraints and annotations</summary>
<pre><code>{{ $s | jsonify (dict "indent" " ") }}</code></pre>
</details>
{{ end }}
{{ else }}
<p>
{{ 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 }}
</p>
{{ end }}

View File

@@ -12,6 +12,7 @@
{{ partialCached "pagemeta.html" . . }}
<hr />
</div>
{{ partial "api-reference/manual-link.html" . }}
{{ .Content }}
{{- if and (eq .Type "guides") (not .IsSection) (ne .CurrentSection .FirstSection) -}}
{{- with .PrevInSection -}}

View File

@@ -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 @@
{{ . }}
</li>
{{- range where $pages "Params.sidebar.group" . }}
{{- if .IsSection }}
{{- if and .IsSection (not .Params.apiID) }}
{{- template "renderList" (dict "section" . "revealHidden" $revealHidden) }}
{{- else }}
{{- template "renderSingle" . }}

369
layouts/api-docs.html Normal file
View File

@@ -0,0 +1,369 @@
{{ define "head-extra" }}
{{ $style := resources.Get "api-reference/reference.css" | fingerprint }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}" />
{{ $script := resources.Get "api-reference/reference.js" | fingerprint }}
<script src="{{ $script.RelPermalink }}" defer></script>
{{ 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" }}
<article class="api-reference" data-api-view="{{ .Params.view }}">
{{ $data := index hugo.Data "api-reference" }}
{{ $api := dict }}{{ range $data.apis }}
{{ if eq .id $.Params.apiID }}{{ $api = . }}{{ end }}
{{ end }}
{{ if eq .Params.view "catalog" }}
<p class="api-eyebrow">Developer reference</p>
<h1>Docker APIs</h1>
<p class="api-lead">
Build with Docker, from your local daemon to hosted services.
</p>
<p>
Choose an API to find connection guidance, operations, and data models.
</p>
<div class="api-cards">
{{ range $data.apis }}
<a class="api-card" href="{{ .url }}"
><span class="api-label"
>{{ if eq .connection "unix" }}
Your daemon
{{ else }}
Hosted service
{{ end }}</span
>
<h2>{{ .title }}</h2>
<p>API {{ .version }} · {{ len .operations }} operations</p>
<span>Explore reference →</span></a
>
{{ end }}
{{ range $data.legacyAPIs }}
<a class="api-card" href="{{ .url }}">
<span class="api-label"
>{{ if eq .connection "unix" }}
Your daemon
{{ else }}
Hosted service
{{ end }}</span
>
<h2>{{ .title }}</h2>
<p>{{ .description }}</p>
<span>Explore reference →</span>
</a>
{{ end }}
</div>
{{ else }}
<nav class="api-crumbs" aria-label="API breadcrumb">
<a href="/reference/api/">APIs</a> /
<a href="{{ $api.url }}">{{ $api.title }}</a> /
<span>API {{ $api.version }}</span>
</nav>
<div class="api-tools">
<label
>API version
<select data-api-version>
{{ range $data.apis }}
{{ if eq .product $api.product }}
<option
value="{{ .url }}"
{{ if eq .id $api.id }}selected{{ end }}
>
{{ .version }}
</option>
{{ end }}
{{ end }}
</select></label
><a href="{{ ref . $api.manual }}">Product manual</a
><a href="{{ $api.sourceURL }}">Download OpenAPI specification</a
><a href="{{ partial "utils/markdown-url.html" . }}">Markdown</a>
</div>
<meta
data-pagefind-meta="title:{{ .Title }} — {{ $api.title }} API {{ $api.version }}"
/>
{{ if eq .Params.view "overview" }}
<p class="api-eyebrow">
{{ if eq $api.connection "unix" }}
User-operated API
{{ else }}
Hosted API
{{ end }}
</p>
<h1>{{ $api.title }} API</h1>
<p class="api-lead">
API {{ $api.version }} · {{ len $api.operations }} operations ·
{{ len $api.schemas }} named schemas
</p>
<section class="api-overview" aria-labelledby="api-overview-title">
<h2 id="api-overview-title">Overview</h2>
<div class="prose dark:prose-invert">
{{ partial "api-reference/description.html" (dict "text" $api.description "api" $api) | markdownify }}
</div>
</section>
<section class="api-connection" id="authentication">
<h2>
{{ if eq $api.connection "unix" }}
Connecting to
{{ $api.title }}
{{ else }}
Connecting to the {{ $api.title }} API
{{ end }}
</h2>
{{ range $name, $scheme := $api.securitySchemes }}
{{ with $scheme.description }}
<h3>{{ index $scheme "x-displayName" | default $name }}</h3>
<div class="prose dark:prose-invert">{{ . | markdownify }}</div>
{{ end }}
{{ end }}
{{ if eq $api.connection "unix" }}
<pre><code>curl --unix-socket /var/run/docker.sock http://localhost/v{{ $api.version }}/version</code></pre>
{{ else }}
{{ range $api.servers }}<code>{{ .url }}</code>{{ end }}
{{ end }}{{ range $api.guides }}
{{ $guide := site.GetPage (index (split . "#") 0) }}
<p><a href="{{ ref $ . }}">{{ $guide.Title }}</a></p>
{{ end }}
</section>
<div class="api-overview">
{{ $overviewTags := partial "api-reference/overview-tags.html" $api }}
{{ range $api.tags }}
{{ if not (in $overviewTags .) }}
<span id="tag-{{ .name | urlize }}"></span>
{{ end }}
{{ end }}
{{ range $overviewTags }}
<section id="tag-{{ .name | urlize }}">
<h2>{{ .summary }}</h2>
<div class="prose dark:prose-invert">
{{ partial "api-reference/description.html" (dict "text" .description "api" $api) | markdownify }}
</div>
</section>
{{ end }}
</div>
<h2>Operations</h2>
<label class="api-filter-label"
>Filter operations
<input
type="search"
placeholder="Method, path, or operation name"
data-api-filter
/></label>
<div class="api-operation-list">
{{ range $api.operations }}
<a class="api-operation-row" href="{{ .url }}" data-api-filter-item
><span class="api-method" data-method="{{ .method }}"
>{{ .method }}</span
><code>{{ partial "api-reference/path.html" .path }}</code
><span>{{ .summary }}</span></a
>
{{ end }}
</div>
<h2 id="schemas">Schemas</h2>
<ul class="api-schema-links" aria-label="Schemas">
{{ range $api.schemas }}
<li>
<a href="{{ .url }}"><code>{{ .name }}</code></a>
</li>
{{ end }}
</ul>
{{ else if eq .Params.view "operation" }}
{{ range $api.operations }}
{{ if eq .id $.Params.operationID }}
<h1>{{ .summary }}</h1>
<div class="api-signature">
<span class="api-method" data-method="{{ .method }}"
>{{ .method }}</span
><code>{{ partial "api-reference/path.html" .path }}</code
>{{ if .deprecated }}
<span>Deprecated</span>
{{ end }}
</div>
<div class="api-operation-grid">
<div class="api-reading">
<div class="api-description prose dark:prose-invert">
{{ partial "api-reference/description.html" (dict "text" .description "api" $api) | markdownify }}
</div>
<h2>Connection and access</h2>
<p>
<a href="{{ $api.url }}#authentication"
>API connection and authentication guidance</a
>
</p>
{{ if and (eq $api.product "engine") (where .parameters "name" "X-Registry-Auth") }}
<p>
<code>X-Registry-Auth</code> delegates registry credentials
and does not authenticate the daemon caller.
</p>
{{ end }}
{{ range .servers }}
<p>
<code>{{ .url }}</code>{{ with .description }}
—
{{ . }}
{{ end }}
</p>
{{ end }}
{{ if not .security }}
<p>
No HTTP authentication requirement is declared for this
operation. Transport access controls can still apply.
</p>
{{ else }}
<p>
Use one of these alternatives. Requirements within an
alternative apply together.
</p>
<ul>
{{ range .security }}
<li>
{{ $first := true }}{{ range $name, $scopes := . }}
{{ if not $first }}
AND
{{ end }}{{ $first = false }}<code>{{ $name }}</code>{{ with $scopes }}
({{ delimit . ", " }})
{{ end }}
{{ end }}{{ if $first }}Anonymous access{{ end }}
</li>
{{ end }}
</ul>
{{ end }}
<h2>Parameters</h2>
{{ if not .parameters }}
<p>No parameters are declared.</p>
{{ end }}
{{ range .parameters }}
<section class="api-field" data-api-parameter="{{ .name }}">
<h3>
<code>{{ .name }}</code>
<small>{{ .in }}</small>
{{ if .required }}
<small class="api-required">Required</small>
{{ end }}
</h3>
<div class="prose dark:prose-invert">
{{ partial "api-reference/description.html" (dict "text" .description "api" $api) | markdownify }}
</div>
{{ if isset . "schema" }}
{{ partial "api-reference/schema.html" (dict "schema" .schema "api" $api "depth" 0) }}
{{ end }}{{ with .content }}
<pre><code>{{ . | jsonify (dict "indent" " ") }}</code></pre>
{{ end }}{{ with .style }}
<p>Serialization: <code>{{ . }}</code></p>
{{ end }}
</section>
{{ end }}
<h2>Request and responses</h2>
<label
>Media type
<select data-api-media-select>
<option value="">All media types</option>
{{ $media := slice }}{{ range .variants }}
{{ if .media }}
{{ $media = $media | append .media }}
{{ end }}
{{ end }}{{ range ($media | uniq | sort) }}
<option value="{{ . }}">{{ . }}</option>
{{ end }}
</select></label
>
{{ range $i, $v := .variants }}
<section
class="api-variant"
data-api-variant="{{ $v.pointer }}"
data-api-media="{{ $v.media }}"
>
<h3>
<span>{{ $v.direction | humanize }}</span>
{{ with $v.status }}
<code class="api-status">{{ . }}</code>
{{ end }}
</h3>
<div class="prose dark:prose-invert">
{{ partial "api-reference/description.html" (dict "text" $v.description "api" $api) | markdownify }}
</div>
{{ if $v.media }}
<p class="api-media"><code>{{ $v.media }}</code></p>
{{ else }}
<p>No response content is declared.</p>
{{ end }}
{{ with $v.headers }}
<h4>Headers</h4>
{{ range $name,$header := . }}
<div class="api-field">
<code>{{ $name }}</code>
<p>{{ $header.description }}</p>
<pre><code>{{ $header | jsonify (dict "indent" " ") }}</code></pre>
</div>
{{ end }}
{{ end }}
{{ if isset $v "schema" }}
{{ partial "api-reference/schema.html" (dict "schema" $v.schema "api" $api "depth" 0) }}
{{ end }}
{{ if isset $v "itemSchema" }}
<h4>Stream item</h4>
{{ partial "api-reference/schema.html" (dict "schema" $v.itemSchema "api" $api "depth" 0) }}
{{ end }}
{{ if $v.examples }}
<div data-api-examples>
{{ if gt (len $v.examples) 1 }}
<label
>Example
<select data-api-example-select>
{{ range $j,$ex := $v.examples }}
<option value="{{ $j }}">{{ $ex.name }}</option>
{{ end }}
</select></label
>
{{ end }}{{ range $j,$ex := $v.examples }}
<div data-api-example="{{ $j }}">
<h4>{{ $ex.name }}</h4>
<pre><code>{{ $ex.text }}</code></pre>
</div>
{{ end }}
</div>
{{ end }}
</section>
{{ end }}
<h2>Referenced schemas</h2>
{{ range .references }}
{{ if .url }}
<p><a href="{{ .url }}">{{ .ref }}</a></p>
{{ end }}
{{ end }}
<details>
<summary>Complete operation contract</summary>
<pre><code>{{ .raw | jsonify (dict "indent" " ") }}</code></pre>
</details>
</div>
<aside class="api-request">
<div class="api-request-heading">
<h2>Example request</h2>
<span>Shell</span>
</div>
<p>
Replace placeholders and supply the required credentials or
request body.
</p>
<pre><code data-api-copy-source>{{ .curl }}</code></pre>
<button type="button" data-api-copy>Copy request</button
>{{ range .curlNotes }}<p>{{ . }}</p>{{ end }}
</aside>
</div>
{{ end }}
{{ end }}
{{ else if eq .Params.view "schema" }}
{{ range $api.schemas }}
{{ if eq .name $.Params.schemaName }}
<p class="api-eyebrow">Schema</p>
<h1>{{ .name }}</h1>
{{ partial "api-reference/schema.html" (dict "schema" .schema "api" $api "depth" 0) }}
{{ end }}
{{ end }}
{{ end }}
{{ end }}
</article>
{{ end }}

View File

@@ -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 }}

View File

@@ -2,6 +2,7 @@
<html lang="en">
<head>
{{ partial "head.html" . }}
{{ block "head-extra" . }}{{ end }}
</head>
<body

View File

@@ -3,6 +3,7 @@ publish = "public"
[context.deploy-preview.environment]
NODE_VERSION = "24"
GO_VERSION = "1.26.5"
NODE_ENV = "production"
HUGO_VERSION = "0.163.0"
HUGO_ENABLEGITINFO = "true"
@@ -10,4 +11,4 @@ HUGO_ENVIRONMENT = "preview"
SECRETS_SCAN_OMIT_PATHS = "public/contribute/file-conventions/index.html"
[context.deploy-preview]
command = "hugo --gc --minify -b $DEPLOY_PRIME_URL && ./hack/flatten-and-resolve.js && npx pagefind@v1.5.2"
command = "./hack/api-docs/run.sh generate && hugo --gc --minify -b $DEPLOY_PRIME_URL && node hack/flatten-and-resolve.js && node hack/api-docs/verify-output.mjs && npx pagefind@v1.5.2"

476
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"highlight.js": "11.11.1",
"marked": "17.0.4",
"mermaid": "11.15.0",
"micromark": "4.0.2",
"tailwindcss": "4.2.1"
},
"devDependencies": {
@@ -1173,11 +1174,24 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1229,6 +1243,15 @@
"node": ">=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",

View File

@@ -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": {