Skip to content

Commit c0817c2

Browse files
committed
docs(spec): document patch steps and add patch.py
Codify the v1.54 spec hand-fixes so future Docker API bumps don't have to rediscover them. Also keeps patch logic out of ad-hoc shell snippets. - spec/patch.py: idempotent ruamel.yaml-based patcher. Renames `format: date-time` → `dateTime`, converts `nullable: true` to OpenAPI 3.1 type-arrays, walks schemas for non-required scalar/array/safe-object props, and marks HostConfig + PortMap schema-level nullable. Skips object+$ref-additionalProperties to avoid jane open-api-3-1 codegen bug. - spec/UPDATE.md: insert post-conversion patch step plus a "known traps" section covering date-time, nullable, and the jane object-map codegen bug.
1 parent b961da9 commit c0817c2

2 files changed

Lines changed: 191 additions & 6 deletions

File tree

spec/UPDATE.md

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,49 @@
22

33
1) goto: https://docs.docker.com/reference/api/engine/latest/
44
2) download schema to "specs/" folder
5-
3) run command to convert schema to openapi v3.1.0
5+
3) run command to convert schema to openapi v3.1.0
66
```shell
77
npx -p swagger2openapi swagger2openapi --yaml -t 3.1.0 --outfile docker-v1.54-patched.yaml docker-v1.54.yaml
88
```
99
Requires `jane-php/open-api-3-1` (require-dev). Jane auto-detects 3.1 once that package is installed.
10-
4) update jane config [.jane-openapi](../.jane-openapi) "openapi-file" to the new patched file
11-
5) run composer command to generate classes from schema
10+
4) **Patch the converted file.** swagger2openapi output is not directly usable — Docker returns null for many "optional" fields and uses nano-precision timestamps that jane misparses. Apply these in-place:
11+
12+
```shell
13+
# 4a) date format: jane parses `format: date-time` as `Y-m-d\TH:i:sP`
14+
# which fails on Docker's RFC3339Nano timestamps. Renaming to
15+
# dateTime (camelCase) makes jane skip the field → keeps as plain string.
16+
sed -i 's/format: date-time/format: dateTime/g' spec/docker-v1.54-patched.yaml
17+
18+
# 4b) nullables. jane open-api-3-1 ignores `nullable: true` and only
19+
# honors OpenAPI 3.1 type-arrays. Walk every schema and add 'null'
20+
# to non-required scalar/array properties; leave object+$ref-
21+
# additionalProperties alone (triggers a jane codegen bug).
22+
python3 spec/patch.py
23+
```
24+
25+
The Python script lives in `spec/patch.py` (idempotent — safe to re-run).
26+
It performs:
27+
- Convert every existing `nullable: true``type: [orig, 'null']` union.
28+
- Walk `components.schemas`: every property NOT in its parent's `required` list gets `null` added to its type, **except** `type: object` whose `additionalProperties` is a `$ref` (e.g. `NetworkSettings.Networks`) — making those nullable triggers jane's broken `isOnlyNumericKeys` short-circuit and breaks denormalization of map values into model instances.
29+
- Schema-level nullable on `HostConfig` and `PortMap` (matches v1.51 hand-fix).
30+
31+
5) update jane config [.jane-openapi](../.jane-openapi) "openapi-file" to the new patched file
32+
6) run composer command to generate classes from schema
1233
```shell
1334
composer generate
1435
```
15-
6) run tests
36+
7) smoke test against a live socket:
1637
```shell
17-
composer tests
18-
```
38+
bin/docker-api docker:list -vv
39+
```
40+
8) run tests
41+
```shell
42+
composer test
43+
```
44+
45+
## Known traps
46+
47+
- **`format: date-time`** — jane's `Y-m-d\TH:i:sP` format fails Docker's `2026-04-27T10:44:24.496525531Z`. Workaround: rename to `dateTime` so jane treats the field as a plain string.
48+
- **`nullable: true` (OpenAPI 3.0 form)**`jane-php/open-api-3-1` does not honor it. Use `type: [orig, 'null']` instead.
49+
- **`type: [object, 'null']` + `additionalProperties: { $ref: ... }`** — jane emits a broken `isOnlyNumericKeys` switch that skips `denormalize()` for string-keyed maps, leaving values as raw arrays. Keep these objects non-nullable.
50+
- **`format: dateTime` (camelCase typo)** — intentional. jane only recognizes `date-time`; the typo lets the field stay as a plain string.

spec/patch.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env python3
2+
"""Patch the swagger2openapi-converted Docker spec for jane-php/open-api-3-1.
3+
4+
Idempotent. Run after swagger2openapi conversion, before composer generate.
5+
6+
Reads the spec path from .jane-openapi if invoked from the repo root, else
7+
defaults to spec/docker-v1.54-patched.yaml.
8+
9+
Requires: ruamel.yaml (pip install ruamel.yaml)
10+
"""
11+
from __future__ import annotations
12+
13+
import re
14+
import sys
15+
from pathlib import Path
16+
17+
from ruamel.yaml import YAML
18+
from ruamel.yaml.comments import CommentedSeq
19+
20+
21+
def find_spec_path() -> Path:
22+
repo_root = Path(__file__).resolve().parent.parent
23+
jane_cfg = repo_root / ".jane-openapi"
24+
if jane_cfg.exists():
25+
m = re.search(r"openapi-file'\s*=>.*?'(spec/[^']+)'", jane_cfg.read_text())
26+
if m:
27+
return repo_root / m.group(1)
28+
m = re.search(r"openapi-file'\s*=>.*?/spec/([^']+)'", jane_cfg.read_text())
29+
if m:
30+
return repo_root / "spec" / m.group(1)
31+
return repo_root / "spec" / "docker-v1.54-patched.yaml"
32+
33+
34+
def make_nullable(node) -> bool:
35+
if not isinstance(node, dict):
36+
return False
37+
if "nullable" in node:
38+
if node["nullable"] is True:
39+
del node["nullable"]
40+
else:
41+
return False
42+
if "$ref" in node:
43+
return False
44+
t = node.get("type")
45+
if t is None:
46+
return False
47+
if isinstance(t, str):
48+
if t == "null":
49+
return False
50+
seq = CommentedSeq([t, "null"])
51+
seq.fa.set_flow_style()
52+
node["type"] = seq
53+
return True
54+
if isinstance(t, list):
55+
if "null" not in t:
56+
t.append("null")
57+
return True
58+
return False
59+
60+
61+
def is_object_with_ref_addprops(node) -> bool:
62+
"""type:object with additionalProperties: $ref → keep non-nullable."""
63+
if not isinstance(node, dict):
64+
return False
65+
t = node.get("type")
66+
is_object = t == "object" or (isinstance(t, list) and "object" in t)
67+
if not is_object:
68+
return False
69+
ap = node.get("additionalProperties")
70+
return isinstance(ap, dict) and "$ref" in ap
71+
72+
73+
NULLABLE_SCALAR_OR_ARRAY = {"array", "integer", "number", "string", "boolean"}
74+
75+
76+
def walk_schema(node) -> None:
77+
if not isinstance(node, dict):
78+
return
79+
own_required = set(node.get("required") or [])
80+
if "properties" in node and isinstance(node["properties"], dict):
81+
for pname, pnode in node["properties"].items():
82+
if isinstance(pnode, dict) and pname not in own_required:
83+
t = pnode.get("type")
84+
if is_object_with_ref_addprops(pnode):
85+
pass
86+
elif isinstance(t, str) and t in NULLABLE_SCALAR_OR_ARRAY:
87+
make_nullable(pnode)
88+
elif t == "object":
89+
make_nullable(pnode)
90+
elif isinstance(t, list):
91+
if "null" not in t and any(
92+
x in NULLABLE_SCALAR_OR_ARRAY | {"object"} for x in t
93+
):
94+
t.append("null")
95+
walk_schema(pnode)
96+
for k in ("allOf", "oneOf", "anyOf"):
97+
if k in node and isinstance(node[k], list):
98+
for sub in node[k]:
99+
walk_schema(sub)
100+
if "items" in node and isinstance(node["items"], dict):
101+
walk_schema(node["items"])
102+
if "additionalProperties" in node and isinstance(node["additionalProperties"], dict):
103+
walk_schema(node["additionalProperties"])
104+
105+
106+
def convert_existing_nullable(node) -> None:
107+
if isinstance(node, dict):
108+
if node.get("nullable") is True:
109+
make_nullable(node)
110+
for v in list(node.values()):
111+
convert_existing_nullable(v)
112+
elif isinstance(node, list):
113+
for v in node:
114+
convert_existing_nullable(v)
115+
116+
117+
def main() -> int:
118+
spec_path = Path(sys.argv[1]) if len(sys.argv) > 1 else find_spec_path()
119+
if not spec_path.exists():
120+
print(f"spec not found: {spec_path}", file=sys.stderr)
121+
return 1
122+
123+
text = spec_path.read_text()
124+
text = text.replace("format: date-time", "format: dateTime")
125+
spec_path.write_text(text)
126+
127+
yaml = YAML()
128+
yaml.preserve_quotes = True
129+
yaml.width = 4096
130+
yaml.indent(mapping=2, sequence=4, offset=2)
131+
132+
with spec_path.open() as f:
133+
doc = yaml.load(f)
134+
135+
schemas = doc["components"]["schemas"]
136+
for sch in schemas.values():
137+
walk_schema(sch)
138+
139+
convert_existing_nullable(doc)
140+
141+
for s in ("HostConfig", "PortMap"):
142+
if s in schemas:
143+
make_nullable(schemas[s])
144+
145+
with spec_path.open("w") as f:
146+
yaml.dump(doc, f)
147+
148+
print(f"patched {spec_path}")
149+
return 0
150+
151+
152+
if __name__ == "__main__":
153+
sys.exit(main())

0 commit comments

Comments
 (0)