okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(apigen): simplify schema projection with modern Go helpersf52e1c5

main
6.4 KiB229 linesraw
1// apigen derives Go wire types and HTTP requests from the daemon contract.
2package main
3
4import (
5	"bytes"
6	"encoding/json"
7	"flag"
8	"fmt"
9	"maps"
10	"os"
11	"path/filepath"
12	"strings"
13
14	"github.com/getkin/kin-openapi/openapi3"
15	"github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen"
16	"go.yaml.in/yaml/v3"
17)
18
19func main() {
20	check := flag.Bool("check", false, "fail if generated code differs")
21	flag.Parse()
22	if err := generate(*check); err != nil {
23		fmt.Fprintln(os.Stderr, err)
24		os.Exit(1)
25	}
26}
27
28func generate(check bool) error {
29	root := filepath.Join("..", "..", "..")
30	data, err := os.ReadFile(filepath.Join(root, "docs", "openapi.yaml"))
31	if err != nil {
32		return err
33	}
34	var document map[string]any
35	if err := yaml.Unmarshal(data, &document); err != nil {
36		return err
37	}
38	// oapi-codegen supports 3.1. SSE item schemas stay in the canonical 3.2
39	// document; the adapter owns framing, delivery and cursor recovery.
40	document["openapi"] = "3.1.0"
41	schemas := document["components"].(map[string]any)["schemas"].(map[string]any)
42	for name, schema := range schemas {
43		project(schema)
44		if name == "DynamicJSON" {
45			schema.(map[string]any)["x-go-type"] = "json.RawMessage"
46		}
47	}
48	projectParameters(document["paths"])
49	projectParameters(document["components"].(map[string]any)["parameters"])
50	data, err = json.Marshal(document)
51	if err != nil {
52		return err
53	}
54	spec, err := openapi3.NewLoader().LoadFromData(data)
55	if err != nil {
56		return err
57	}
58	version := "v2.8.0"
59	source, err := codegen.Generate(spec, codegen.Configuration{
60		PackageName:   "protocol",
61		Generate:      codegen.GenerateOptions{Models: true, Client: true},
62		Compatibility: codegen.CompatibilityOptions{DisableRequiredReadOnlyAsPointer: true},
63		OutputOptions: codegen.OutputOptions{
64			SkipPrune:             true,
65			SkipEnumValidate:      true,
66			NameNormalizer:        "ToCamelCaseWithInitialisms",
67			AdditionalInitialisms: []string{"UTF8", "TTL", "MCP", "HTTP", "JJ"},
68			UserTemplates:         map[string]string{"client-with-responses.tmpl": "// Response decoding is owned by the daemon adapter.\n"},
69		},
70		NoVCSVersionOverride: &version,
71	})
72	if err != nil {
73		return err
74	}
75	target := filepath.Join(root, "cli", "internal", "daemon", "protocol", "api.gen.go")
76	if check {
77		current, err := os.ReadFile(target)
78		if err != nil {
79			return err
80		}
81		if !bytes.Equal(current, []byte(source)) {
82			return fmt.Errorf("generated API is stale; run go generate ./internal/daemon")
83		}
84		return nil
85	}
86	return os.WriteFile(target, []byte(source), 0644)
87}
88
89// project selects Go representations without changing the wire contract.
90// Unions remain raw JSON for the adapter's discriminated decoding. Scalars
91// keep their wire values; timestamps are parsed by the application adapter.
92func project(value any) {
93	switch node := value.(type) {
94	case []any:
95		for _, child := range node {
96			project(child)
97		}
98	case map[string]any:
99		for _, key := range []string{"properties", "$defs", "items", "prefixItems", "anyOf", "oneOf", "allOf", "additionalProperties"} {
100			child := node[key]
101			if key == "properties" || key == "$defs" {
102				if members, ok := child.(map[string]any); ok {
103					for _, member := range members {
104						project(member)
105					}
106				}
107			} else {
108				project(child)
109			}
110		}
111		if properties, ok := node["properties"].(map[string]any); ok {
112			for name, child := range properties {
113				property := child.(map[string]any)
114				if name == "etag" {
115					property["x-go-name"] = "ETag"
116				}
117				if baseName, hasIDsSuffix := strings.CutSuffix(name, "_ids"); hasIDsSuffix {
118					property["x-go-name"] = codegen.ToCamelCaseWithInitialisms(baseName) + "IDs"
119				}
120				kind, _ := property["type"].(string)
121				if kind == "array" || property["x-go-type"] == "json.RawMessage" || (kind == "object" && property["properties"] == nil) {
122					property["x-go-type-skip-optional-pointer"] = true
123				}
124			}
125		}
126		if items, ok := node["items"].(bool); ok && !items {
127			// Object-form false schema keeps the tuple restriction while
128			// avoiding the loader's unsupported boolean items schema.
129			node["items"] = map[string]any{"not": map[string]any{}}
130		}
131		if constant, ok := node["const"]; ok {
132			switch constant.(type) {
133			case string:
134				node["type"] = "string"
135			case int, int64:
136				node["type"] = "integer"
137			case bool:
138				node["type"] = "boolean"
139			}
140		}
141		kind, _ := node["type"].(string)
142		nullable := kind == "null"
143		if kinds, ok := node["type"].([]any); ok {
144			for _, member := range kinds {
145				if member == "null" {
146					nullable = true
147				}
148			}
149			if len(kinds) == 2 && nullable {
150				for _, member := range kinds {
151					if member != "null" {
152						kind = member.(string)
153					}
154				}
155			} else {
156				node["x-go-type"] = "json.RawMessage"
157			}
158		}
159		for _, keyword := range []string{"anyOf", "oneOf"} {
160			members, ok := node[keyword].([]any)
161			if !ok {
162				continue
163			}
164			var nonnull []any
165			for _, member := range members {
166				if member.(map[string]any)["type"] == "null" {
167					nullable = true
168				} else {
169					nonnull = append(nonnull, member)
170				}
171			}
172			if len(nonnull) == 1 && nullable && nonnull[0].(map[string]any)["type"] == "array" {
173				// Inline nullable arrays otherwise lose their array shape in
174				// oapi-codegen's single-branch union handling.
175				maps.Copy(node, nonnull[0].(map[string]any))
176				node["type"] = []any{"array", "null"}
177				delete(node, keyword)
178				kind = "array"
179			} else if node["properties"] != nil {
180				delete(node, keyword)
181			} else if len(nonnull) != 1 || !nullable {
182				node["x-go-type"] = "json.RawMessage"
183			}
184		}
185		switch kind {
186		case "string":
187			node["x-go-type"] = "string"
188		case "integer":
189			node["x-go-type"] = "int64"
190		case "number":
191			node["x-go-type"] = "float64"
192		case "boolean":
193			node["x-go-type"] = "bool"
194		}
195		if node["x-go-type"] == "json.RawMessage" {
196			delete(node, "anyOf")
197			delete(node, "oneOf")
198			delete(node, "type")
199			node["x-go-type-skip-optional-pointer"] = true
200		}
201		if kind == "array" {
202			node["x-go-type-skip-optional-pointer"] = true
203		}
204		if nullable {
205			node["x-oapi-codegen-extra-tags"] = map[string]any{"nullable": "true"}
206		}
207		if len(node) == 0 {
208			node["x-go-type"] = "json.RawMessage"
209		}
210
211	}
212}
213
214func projectParameters(value any) {
215	switch node := value.(type) {
216	case []any:
217		for _, child := range node {
218			projectParameters(child)
219		}
220	case map[string]any:
221		for key, child := range node {
222			if key == "schema" || key == "itemSchema" {
223				project(child)
224			} else if key != "examples" && key != "example" {
225				projectParameters(child)
226			}
227		}
228	}
229}