From b3a90ff6805ab303d749215cb037628d05706dc9 Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Fri, 26 Jun 2026 14:53:16 +0200 Subject: [PATCH 1/9] feat: generate initial modules --- .gitignore | 5 +++ Makefile | 5 +++ cmd/main.go | 5 +++ cmd/root.go | 55 ++++++++++++++++++++++++++ docs/step-00-plan.md | 61 +++++++++++++++++++++++++++++ docs/step-01-empty-structs.md | 72 +++++++++++++++++++++++++++++++++++ go.mod | 7 ++++ go.sum | 10 +++++ model/article.go | 3 ++ model/category.go | 3 ++ model/model.go | 6 +-- model/product.go | 3 ++ 12 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 Makefile create mode 100644 cmd/main.go create mode 100644 cmd/root.go create mode 100644 docs/step-00-plan.md create mode 100644 docs/step-01-empty-structs.md create mode 100644 go.sum create mode 100644 model/article.go create mode 100644 model/category.go create mode 100644 model/product.go diff --git a/.gitignore b/.gitignore index 8d2440a..6f1ac9f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ tags tags result .direnv + +# Ignore vendor directory +vendor/ +# Ignore generated files +generator \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..117c7ae --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +run: + go run ./cmd + +build: + go build -o bin/generator ./cmd \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..736ef31 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + Execute() +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c1464eb --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/amboss-mededu/go-coding-challenge/internal/generator" + "github.com/spf13/cobra" +) + +var ( + schemasDir string + outputDir string +) + +var rootCmd = &cobra.Command{ + Use: "generate", + Short: "Generate Go types from JSON Schema files", + Long: `Reads JSON Schema files from the given directory and produces +one Go source file per schema in the output directory.`, + RunE: runGenerate, +} + +func init() { + rootCmd.Flags().StringVarP(&schemasDir, "schemas", "s", "./schemas", "directory containing JSON Schema files") + rootCmd.Flags().StringVarP(&outputDir, "output", "o", "./model", "output directory for generated Go files") +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func runGenerate(cmd *cobra.Command, args []string) error { + g, err := generator.NewGenerator(schemasDir) + if err != nil { + return err + } + files, err := g.Generate() + if err != nil { + return err + } + for filename, src := range files { + path := filepath.Join(outputDir, filename) + if err := os.WriteFile(path, src, 0644); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "wrote %s\n", path) + } + return nil +} diff --git a/docs/step-00-plan.md b/docs/step-00-plan.md new file mode 100644 index 0000000..10b4e44 --- /dev/null +++ b/docs/step-00-plan.md @@ -0,0 +1,61 @@ +# Step 00 — Full Project Plan + +## Challenge Summary + +Build a CLI code generator that reads JSON Schema files from `schemas/` and emits +faithful Go type definitions into `model/`. The generator must handle the full +spectrum of features present in the three provided schemas (Product, Category, Article). + +## Approach + +Incremental, spec-driven development. Each step has its own spec document in `docs/`, +adds one schema concept, and leaves `go test ./...` passing before moving on. +The CLI (`go run ./cmd`) is the single entry point throughout. + +## Schema Complexity Overview + +| Feature | Where it appears | +|---------|-----------------| +| Primitive fields (string, number, integer, boolean) | Product, Category, Article | +| Required vs optional fields | All three schemas | +| Nullable fields (`string\|null`, `object\|null`) | Product (description), Category (parent), Article (metadata, text) | +| Array fields | Product (tags), Article (authors, nodes) | +| Enum types | Product (classification, unit), Category (kind), Article (status, format) | +| Inline nested objects | Product (dimensions), Article (dimensions, metadata) | +| Cross-schema `$ref` | Article → Category | +| Local definitions (`$defs`) | Article (RichText, PlainText, RichTextNode) | +| `oneOf` discriminated union | Article (content: RichText or PlainText) | +| Recursive types | Article → RichTextNode (children: []RichTextNode) | +| `const` fields | Article → RichText (format: "richtext"), PlainText (format: "plaintext") | + +## Incremental Steps + +| Step | What it adds | Schemas exercised | +|------|-------------|-------------------| +| **01** — Empty structs | One empty struct per schema title, one file per schema | All three | +| **02** — Primitive fields | Required + optional primitive fields (string, number, integer, boolean) with JSON struct tags | Product, Category | +| **03** — Nullable fields | Optional/nullable fields encoded as pointer types (`*string`, `*int`) | Product (description), Category (parent) | +| **04** — Array fields | Array fields (`[]string`, `[]T`) | Product (tags), Article (authors) | +| **05** — Enums | Custom string type + `const` block per enum; fields reference the new type | Product (classification, unit), Category (kind), Article (status) | +| **06** — Inline nested objects | Named sub-struct for each inline object definition | Product (dimensions), Article (dimensions, metadata) | +| **07** — Cross-schema `$ref` | Resolve `$ref` to another schema file as a field of the referenced type | Article (category → Category) | +| **08** — Local `$defs` | Resolve intra-schema `$defs` references; emit named types for each definition | Article (RichText, PlainText, RichTextNode) | +| **09** — `oneOf` union | Interface type + `UnmarshalJSON` discriminator for `oneOf` fields | Article (content: RichText \| PlainText) | +| **10** — Recursive types | Self-referencing struct fields (circular slice) | Article (RichTextNode.children) | + +## Key Design Decisions (to revisit per step) + +- **Optional fields** — pointer types (`*T`) rather than wrapper structs; idiomatic Go and works cleanly with `encoding/json`. +- **Enums** — named `string` type with typed constants; preserves the underlying JSON value without custom marshal logic. +- **`oneOf` discriminated union** — a common interface implemented by each variant, with a holding struct that uses a custom `UnmarshalJSON` to inspect the discriminator field (`format` const) and decode into the correct concrete type. +- **`$ref` resolution** — at code-generation time, not runtime; a `$ref` becomes a direct Go type reference. +- **Recursive types** — Go handles this natively via slice fields; no special treatment needed beyond correct `$defs` resolution. +- **File-per-schema** — one `.go` file per top-level schema; `$defs` sub-types live in the same file as their parent schema. + +## Definition of Done (full challenge) + +- `go run ./cmd` regenerates `model/` from `schemas/` without errors. +- `go test ./...` passes all 8 harness subtests. +- Generated types faithfully represent every schema feature listed above. +- `go vet ./...` is clean. +- A project README covers how to run the generator and the decisions made. diff --git a/docs/step-01-empty-structs.md b/docs/step-01-empty-structs.md new file mode 100644 index 0000000..dceda44 --- /dev/null +++ b/docs/step-01-empty-structs.md @@ -0,0 +1,72 @@ +# Step 01 — Empty Named Structs + +## Goal + +Implement the first working slice of the code generator: read every JSON Schema file +in the `schemas/` directory, extract each schema's `title` field, and emit one Go +source file per schema into the output directory (`model/` by default). Each file +declares a single empty struct named after the schema title. + +## Inputs + +| Source | Description | +|--------|-------------| +| `schemas/*.json` | One JSON Schema file per domain type. Each file must have a top-level `"title"` string field whose value is a valid Go identifier. | + +Current schemas and their titles: + +| File | Title | +|------|-------| +| `schemas/Product.json` | `Product` | +| `schemas/Category.json` | `Category` | +| `schemas/Article.json` | `Article` | + +## Output + +One `.go` file per schema in the output directory (default `./model/`). + +| Schema file | Generated file | Content | +|-------------|----------------|---------| +| `schemas/Article.json` | `model/article.go` | `package model` + `type Article struct{}` | +| `schemas/Category.json` | `model/category.go` | `package model` + `type Category struct{}` | +| `schemas/Product.json` | `model/product.go` | `package model` + `type Product struct{}` | + +Filenames are `strings.ToLower(title) + ".go"`. Each file is `gofmt`-formatted. + +## CLI invocation + +```sh +go run ./cmd --schemas ./schemas --output ./model +``` + +Or using defaults: + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits with status 0 and prints one `wrote ...` line per schema +- [ ] `model/article.go`, `model/category.go`, `model/product.go` exist with correct empty structs +- [ ] `go test ./...` passes (harness unmarshals payloads into the types) +- [ ] `go vet ./...` reports no issues +- [ ] Output is identical across repeated runs (deterministic) + +## Error handling contract + +| Condition | Behaviour | +|-----------|-----------| +| `--schemas` directory does not exist | Return error, exit non-zero | +| A `.json` file fails to parse | Return error naming the file, exit non-zero | +| A parsed schema has an empty `title` | Skip the file with a warning to stderr | +| Output directory does not exist | `os.WriteFile` returns error, exit non-zero | + +## Out of scope + +- Struct fields (added in later steps) +- Optionality / pointer vs value semantics +- Nested/inline types, enums, `oneOf`, `$ref` resolution +- Schema validation beyond "file parses as JSON with a title field" +- Identifier sanitization for titles containing hyphens or spaces +- Cleaning up stale `.go` files in the output directory diff --git a/go.mod b/go.mod index 77b19ba..4456ce2 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ module github.com/amboss-mededu/go-coding-challenge go 1.26 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/model/article.go b/model/article.go new file mode 100644 index 0000000..48a3e1c --- /dev/null +++ b/model/article.go @@ -0,0 +1,3 @@ +package model + +type Article struct{} diff --git a/model/category.go b/model/category.go new file mode 100644 index 0000000..7a6f7ac --- /dev/null +++ b/model/category.go @@ -0,0 +1,3 @@ +package model + +type Category struct{} diff --git a/model/model.go b/model/model.go index 9abca2e..91b2cc9 100644 --- a/model/model.go +++ b/model/model.go @@ -1,7 +1,7 @@ package model -type Product struct{} +type Productt struct{} -type Category struct{} +type Categoryy struct{} -type Article struct{} +type Articlee struct{} diff --git a/model/product.go b/model/product.go new file mode 100644 index 0000000..af24e5c --- /dev/null +++ b/model/product.go @@ -0,0 +1,3 @@ +package model + +type Product struct{} From 6d10087afd224cf7b1a3a19254a5727e03b4c434 Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Fri, 26 Jun 2026 14:57:28 +0200 Subject: [PATCH 2/9] feat: generate initial modules --- .gitignore | 2 +- internal/generator/generator.go | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 internal/generator/generator.go diff --git a/.gitignore b/.gitignore index 6f1ac9f..bab0d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ result # Ignore vendor directory vendor/ # Ignore generated files -generator \ No newline at end of file +bin/ diff --git a/internal/generator/generator.go b/internal/generator/generator.go new file mode 100644 index 0000000..7875422 --- /dev/null +++ b/internal/generator/generator.go @@ -0,0 +1,83 @@ +package generator + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "os" + "path/filepath" + "strings" +) + +// Schema represents a JSON Schema node. +type Schema struct { + Title string `json:"title"` // Title is required for named types. + Type json.RawMessage `json:"type"` // Type can be a string or an array of strings. + Required []string `json:"required"` // Required property names (only for object types). + Properties map[string]*Schema `json:"properties"` // Property name → schema (only for object types). + Items *Schema `json:"items"` // Items schema (only for array types). + Ref string `json:"$ref"` // Reference to another schema (only for object types). + Defs map[string]*Schema `json:"$defs"` // Definitions for local references (only for object types). + Enum []json.RawMessage `json:"enum"` // Enum values (only for string, number, or integer types). + Const json.RawMessage `json:"const"` // Const value (only for string, number, or integer types). + OneOf []*Schema `json:"oneOf"` // OneOf schemas (only for union types). +} + +// Generator walks JSON Schemas and emits Go source. +type Generator struct { + dir string + schemas []*Schema // schemas list of schemas read from the directory +} + +// NewGenerator reads all JSON Schema files in dir and returns a Generator. +func NewGenerator(dir string) (*Generator, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading schemas dir %q: %w", dir, err) + } + + g := &Generator{dir: dir} + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %q: %w", path, err) + } + var s Schema + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parsing %q: %w", path, err) + } + if s.Title == "" { + fmt.Fprintf(os.Stderr, "warning: %q has no title, skipping\n", path) + continue + } + g.schemas = append(g.schemas, &s) + } + + return g, nil +} + +// Generate returns a map of filename → gofmt-formatted Go source, one entry per schema. +func (g *Generator) Generate() (map[string][]byte, error) { + result := make(map[string][]byte, len(g.schemas)) + + for _, s := range g.schemas { + var buf bytes.Buffer + fmt.Fprintf(&buf, "package model\n\ntype %s struct{}\n", s.Title) + + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("formatting source for %q: %w", s.Title, err) + } + + filename := strings.ToLower(s.Title) + ".go" + result[filename] = src + } + + return result, nil +} From b1e49523583734328214b127d73815529bfad4a4 Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Sat, 27 Jun 2026 07:56:40 +0200 Subject: [PATCH 3/9] feat: implementation of step 02 and 03, primitive fields and nullable fields --- Makefile | 5 +- docs/step-02-primitive-fields.md | 158 +++++++++++++++++++++++ docs/step-03-nullable-fields.md | 208 +++++++++++++++++++++++++++++++ internal/generator/generator.go | 87 +++++++++++-- internal/generator/helpers.go | 25 ++++ model/article.go | 6 +- model/category.go | 7 +- model/product.go | 10 +- 8 files changed, 494 insertions(+), 12 deletions(-) create mode 100644 docs/step-02-primitive-fields.md create mode 100644 docs/step-03-nullable-fields.md create mode 100644 internal/generator/helpers.go diff --git a/Makefile b/Makefile index 117c7ae..78f4612 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,7 @@ run: go run ./cmd build: - go build -o bin/generator ./cmd \ No newline at end of file + go build -o bin/generator ./cmd + +test: + go clean -testcache && go test ./... diff --git a/docs/step-02-primitive-fields.md b/docs/step-02-primitive-fields.md new file mode 100644 index 0000000..e0cca17 --- /dev/null +++ b/docs/step-02-primitive-fields.md @@ -0,0 +1,158 @@ +# Step 02 — Primitive Fields + +## Goal + +Extend the generator to emit struct fields for every property whose JSON Schema type is +a single primitive string: `"string"`, `"number"`, `"integer"`, or `"boolean"`. Required +fields get a plain value type; optional fields get `,omitempty` in their JSON tag. +No other schema features (nullable arrays, nested objects, enums) are touched in this step. + +## Inputs + +Same as step 01: `schemas/*.json` files. Two schemas exercise primitive fields in this step. + +### Fields in scope (single primitive type) + +**Product.json** — required: `id`, `name`, `price` + +| Property | JSON type | Required | Go type | +|------------------|-------------|----------|-----------| +| `id` | `"string"` | yes | `string` | +| `name` | `"string"` | yes | `string` | +| `price` | `"number"` | yes | `float64` | +| `quantity` | `"integer"` | no | `int` | +| `active` | `"boolean"` | no | `bool` | +| `classification` | `"string"` | no | `string` | + +Skipped for now: `description` (nullable), `tags` (array), `dimensions` (object). + +**Category.json** — no required fields + +| Property | JSON type | Required | Go type | +|----------|------------|----------|----------| +| `id` | `"string"` | no | `string` | +| `name` | `"string"` | no | `string` | +| `kind` | `"string"` | no | `string` | + +Skipped for now: `parent` (nullable). + +**Article.json** — required: `id`, `title` + +| Property | JSON type | Required | Go type | +|----------|------------|----------|----------| +| `id` | `"string"` | yes | `string` | +| `title` | `"string"` | yes | `string` | +| `status` | `"string"` | no | `string` | + +Skipped for now: `authors` (array), `metadata` (nullable object), `category` ($ref), `dimensions` (object), `content` (oneOf). + +## Type mapping + +| JSON Schema type | Go type | +|------------------|-----------| +| `"string"` | `string` | +| `"number"` | `float64` | +| `"integer"` | `int` | +| `"boolean"` | `bool` | + +## Field name conversion + +JSON property names → exported Go identifiers via `toPascalCase`: splits on `_` and `-` +delimiters and capitalises each word boundary; camelCase interior capitals are preserved. +This handles single words, snake_case, kebab-case, and camelCase uniformly. + +| JSON name | Go name | +|------------------|------------------| +| `id` | `Id` | +| `name` | `Name` | +| `price` | `Price` | +| `quantity` | `Quantity` | +| `active` | `Active` | +| `classification` | `Classification` | +| `kind` | `Kind` | +| `wordCount` | `WordCount` | +| `reading-time` | `ReadingTime` | + +## Required vs optional + +- **Required field** — plain value type, JSON tag without `omitempty`: + + ```go + Price float64 `json:"price"` + ``` + +- **Optional field** — plain value type (pointer types come in step 03), JSON tag with `omitempty`: + + ```go + Quantity int `json:"quantity,omitempty"` + ``` + +## Output + +### model/product.go + +```go +package model + +type Product struct { + Active bool `json:"active,omitempty"` + Classification string `json:"classification,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` +} +``` + +### model/category.go + +```go +package model + +type Category struct { + Id string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` +} +``` + +### model/article.go + +```go +package model + +type Article struct { + Id string `json:"id"` + Status string `json:"status,omitempty"` + Title string `json:"title"` +} +``` + +Fields are sorted alphabetically for deterministic output across runs. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/product.go` and `model/category.go` contain the exact struct bodies above +- [ ] `go test ./...` passes all 8 harness subtests +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 01. No new error conditions introduced. + +## Out of scope + +- Nullable types (`["string", "null"]`) — step 03 +- Array fields — step 04 +- Enum-constrained fields rendered as custom types — step 05 +- Inline nested objects — step 06 +- `$ref`, `$defs`, `oneOf`, recursive types — steps 07–10 +- Pointer types for optional fields — step 03 diff --git a/docs/step-03-nullable-fields.md b/docs/step-03-nullable-fields.md new file mode 100644 index 0000000..5d6595e --- /dev/null +++ b/docs/step-03-nullable-fields.md @@ -0,0 +1,208 @@ +# Step 03 — Nullable Fields + +## Goal + +Extend the generator to recognise **nullable primitive fields** — properties whose JSON Schema +`"type"` is an array of two strings, one of which is `"null"` (e.g. `["string", "null"]`). +These fields are emitted as **pointer types** (`*string`, `*float64`, etc.) so that `null` in +JSON round-trips correctly as `nil` in Go. All other behaviour from step 02 is unchanged. + +## Inputs + +Same as step 02: `schemas/*.json`. Two schemas introduce nullable primitives in this step. + +### Fields in scope (nullable primitive) + +#### Product.json + +| Property | JSON type | Required | Go type | +|---------------|----------------------|----------|-----------| +| `description` | `["string", "null"]` | no | `*string` | + +#### Category.json + +| Property | JSON type | Required | Go type | +|----------|----------------------|----------|-----------| +| `parent` | `["string", "null"]` | no | `*string` | + +**Article.json** — no nullable primitive fields; output is unchanged from step 02. + +## Nullable type detection + +A schema node is nullable when its `"type"` is a JSON array of exactly two elements where one +is `"null"` and the other is a primitive type name. Order does not matter. + +| JSON Schema type | Go type | +|------------------------|------------| +| `["string", "null"]` | `*string` | +| `["null", "string"]` | `*string` | +| `["integer", "null"]` | `*int` | +| `["number", "null"]` | `*float64` | +| `["boolean", "null"]` | `*bool` | + +Fields whose `"type"` is an array of more than two elements, or whose non-null element is not a +known primitive, remain skipped (as in step 02). + +## Why two unmarshal attempts + +`Schema.Type` is stored as `json.RawMessage` because the JSON Schema spec allows `"type"` to be +either a JSON string or a JSON array. Go cannot decode both shapes into the same typed field, so +`goType` tries each in sequence: + +1. `json.Unmarshal(s.Type, &single string)` — succeeds for `"string"`, `"integer"`, etc. +2. `json.Unmarshal(s.Type, &multi []string)` — succeeds for `["string", "null"]`, etc. + If the array has exactly two elements and one is `"null"`, the other names the primitive type + and we prepend `"*"` to the mapped Go type. + +## Required vs nullable + +A nullable field may or may not be required. The existing `Generate()` tag logic already handles +both cases — no changes needed there: + +- **Not required** (all nullable fields in current schemas): + + ```go + Description *string `json:"description,omitempty"` + Parent *string `json:"parent,omitempty"` + ``` + +- **Required nullable** (hypothetical — not in current schemas): + + ```go + SomeField *string `json:"someField"` + ``` + +## Implementation + +### `setGoType` / `setPrimitiveType` in `internal/generator/generator.go` + +The type resolution is split into two functions. `setGoType` guards on a nil `Type`, then delegates to `setPrimitiveType` which handles both JSON shapes: + +```go +func setGoType(s *Schema) string { + if s.Type == nil { + return "" + } + return setPrimitiveType(s) +} + +func setPrimitiveType(s *Schema) string { + // Case 1: "type": "string" → single JSON string + var single string + if err := json.Unmarshal(s.Type, &single); err == nil { + return primitiveTypes[single] + } + // Case 2: "type": ["string", "null"] → nullable primitive (order-independent) + var multi []string + if err := json.Unmarshal(s.Type, &multi); err == nil && len(multi) == 2 { + for _, t := range multi { + if t != "null" { + if gt := primitiveTypes[t]; gt != "" { + return "*" + gt + } + } + } + } + return "" +} +``` + +### `Generate()` refactor + +`Generate()` was also refactored to remove the `wrote` flag and `buf.Reset()` dance. Fields are now accumulated into a separate `fields` buffer first; the struct header is only written once the outcome is known: + +```go +var fields bytes.Buffer +for _, property := range propertyNames { + goType := setGoType(s.Properties[property]) + if goType == "" { + continue + } + tag := property + if !requiredFields[property] { + tag += ",omitempty" + } + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(property), goType, tag) +} + +if fields.Len() > 0 { + fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", s.Title, fields.String()) + // format.Source + write to schemasMap ... +} +``` + +Note: schemas with no mappable fields produce no output file (rather than an empty struct). All three current schemas have at least one primitive field so this does not affect the current output. + +No changes required in `NewGenerator`. + +## Output + +### model/product.go + +```go +package model + +type Product struct { + Active bool `json:"active,omitempty"` + Classification string `json:"classification,omitempty"` + Description *string `json:"description,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` +} +``` + +### model/category.go + +```go +package model + +type Category struct { + Id string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Parent *string `json:"parent,omitempty"` +} +``` + +### model/article.go + +```go +package model + +type Article struct { + Id string `json:"id"` + Status string `json:"status,omitempty"` + Title string `json:"title"` +} +``` + +Fields are sorted alphabetically for deterministic output across runs. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/product.go` contains `Description *string \`json:"description,omitempty"\`` +- [ ] `model/category.go` contains `Parent *string \`json:"parent,omitempty"\`` +- [ ] `go test ./...` passes all 8 harness subtests (including `4.json` with `"parent": null` and `5.json` with `"parent": "cat-1"`) +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 02. No new error conditions introduced. + +## Out of scope + +- Nullable object types (`["object", "null"]`) — step 06+ +- Array fields — step 04 +- Enum-constrained fields as custom types — step 05 +- Inline nested objects — step 06 +- `$ref`, `$defs`, `oneOf`, recursive types — steps 07–10 diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 7875422..466f4ca 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -7,6 +7,7 @@ import ( "go/format" "os" "path/filepath" + "sort" "strings" ) @@ -30,6 +31,13 @@ type Generator struct { schemas []*Schema // schemas list of schemas read from the directory } +var primitiveTypes = map[string]string{ + "string": "string", + "number": "float64", + "integer": "int", + "boolean": "bool", +} + // NewGenerator reads all JSON Schema files in dir and returns a Generator. func NewGenerator(dir string) (*Generator, error) { entries, err := os.ReadDir(dir) @@ -64,20 +72,83 @@ func NewGenerator(dir string) (*Generator, error) { // Generate returns a map of filename → gofmt-formatted Go source, one entry per schema. func (g *Generator) Generate() (map[string][]byte, error) { - result := make(map[string][]byte, len(g.schemas)) + schemasMap := make(map[string][]byte, len(g.schemas)) for _, s := range g.schemas { var buf bytes.Buffer - fmt.Fprintf(&buf, "package model\n\ntype %s struct{}\n", s.Title) - src, err := format.Source(buf.Bytes()) - if err != nil { - return nil, fmt.Errorf("formatting source for %q: %w", s.Title, err) + requiredFields := make(map[string]bool, len(s.Required)) + for _, r := range s.Required { + requiredFields[r] = true + } + + // TODO, i think i can avoid this loop and sorting by adding maybe a flag to the schema + // that tells me if the property was already processed that way i can have idempotency + propertyNames := make([]string, 0, len(s.Properties)) + for propertyName := range s.Properties { + propertyNames = append(propertyNames, propertyName) } + sort.Strings(propertyNames) - filename := strings.ToLower(s.Title) + ".go" - result[filename] = src + var fields bytes.Buffer + for _, property := range propertyNames { + goType := setGoType(s.Properties[property]) + if goType == "" { + continue + } + + tag := property + if !requiredFields[property] { + tag += ",omitempty" + } + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(property), goType, tag) + } + + if fields.Len() > 0 { + fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", s.Title, fields.String()) + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("formatting source for %q: %w", s.Title, err) + } + + filename := strings.ToLower(s.Title) + ".go" + schemasMap[filename] = src + } } - return result, nil + return schemasMap, nil +} + +// setGoType returns the Go type string for a schema node, or "" if the type cannot +// be mapped to a primitive (array, object, multi-type beyond nullable, etc.). +// Handles both single-string types ("string") and nullable pairs (["string","null"]). +func setGoType(s *Schema) string { + if s.Type == nil { + return "" + } + + return setPrimitiveType(s) +} + +// setPrimitiveType returns the Go type string for a schema node, or "" if the type cannot +// be mapped to a primitive (array, object, multi-type beyond nullable, etc.). +// Handles both single-string types ("string") and nullable pairs (["string","null"]). +func setPrimitiveType(s *Schema) string { + // Case 1: "type": "string" — single primitive + var single string + if err := json.Unmarshal(s.Type, &single); err == nil { + return primitiveTypes[single] + } + // Case 2: "type": ["string", "null"] — nullable primitive (order-independent) + var multi []string + if err := json.Unmarshal(s.Type, &multi); err == nil && len(multi) == 2 { + for _, t := range multi { + if t != "null" { + if gt := primitiveTypes[t]; gt != "" { + return "*" + gt + } + } + } + } + return "" } diff --git a/internal/generator/helpers.go b/internal/generator/helpers.go new file mode 100644 index 0000000..c072a38 --- /dev/null +++ b/internal/generator/helpers.go @@ -0,0 +1,25 @@ +package generator + +import ( + "strings" + "unicode" +) + +// toPascalCase converts camelCase, snake_case, and kebab-case identifiers to PascalCase. +func toPascalCase(s string) string { + var b strings.Builder + upper := true + for _, r := range s { + if r == '_' || r == '-' { + upper = true + continue + } + if upper { + b.WriteRune(unicode.ToUpper(r)) + upper = false + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/model/article.go b/model/article.go index 48a3e1c..0d96153 100644 --- a/model/article.go +++ b/model/article.go @@ -1,3 +1,7 @@ package model -type Article struct{} +type Article struct { + Id string `json:"id"` + Status string `json:"status,omitempty"` + Title string `json:"title"` +} diff --git a/model/category.go b/model/category.go index 7a6f7ac..2c2cf69 100644 --- a/model/category.go +++ b/model/category.go @@ -1,3 +1,8 @@ package model -type Category struct{} +type Category struct { + Id string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Parent *string `json:"parent,omitempty"` +} diff --git a/model/product.go b/model/product.go index af24e5c..99d8b24 100644 --- a/model/product.go +++ b/model/product.go @@ -1,3 +1,11 @@ package model -type Product struct{} +type Product struct { + Active bool `json:"active,omitempty"` + Classification string `json:"classification,omitempty"` + Description *string `json:"description,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` +} From 4bf15fa81f509d5f43cd4c4017e47b4edd909dab Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Sat, 27 Jun 2026 09:54:19 +0200 Subject: [PATCH 4/9] feat: add array and ennums fields --- docs/step-04-array-fields.md | 189 +++++++++++++++++++++ docs/step-05-enums.md | 283 ++++++++++++++++++++++++++++++++ internal/generator/generator.go | 85 ++++++++-- model/article.go | 15 +- model/category.go | 17 +- model/product.go | 24 ++- 6 files changed, 585 insertions(+), 28 deletions(-) create mode 100644 docs/step-04-array-fields.md create mode 100644 docs/step-05-enums.md diff --git a/docs/step-04-array-fields.md b/docs/step-04-array-fields.md new file mode 100644 index 0000000..78db9f1 --- /dev/null +++ b/docs/step-04-array-fields.md @@ -0,0 +1,189 @@ +# Step 04 — Array Fields + +## Goal + +Extend the generator to recognise **array-typed fields** — properties whose JSON Schema +`"type"` is `"array"` and whose `"items"` resolves to a known primitive (or nullable +primitive). These fields are emitted as **slice types** (`[]string`, `[]float64`, etc.). +All other behaviour from step 03 is unchanged. + +## Inputs + +Same as step 03: `schemas/*.json`. Two schemas introduce arrays in this step. + +### Fields in scope (array of primitive) + +#### Product.json + +| Property | JSON type | Items type | Required | Go type | +|----------|-----------|------------|----------|------------| +| `tags` | `"array"` | `"string"` | no | `[]string` | + +#### Article.json + +| Property | JSON type | Items type | Required | Go type | +|-----------|-----------|------------|----------|------------| +| `authors` | `"array"` | `"string"` | no | `[]string` | + +**Category.json** — no array fields; output is unchanged from step 03. + +## Array type detection + +A schema node is an array when its `"type"` is the single JSON string `"array"` and +`"items"` is present. The element type is resolved by calling `setGoType` recursively +on `s.Items`, so nullable item types are handled for free without extra logic. + +| items schema | Go type | +|------------------------------|-------------| +| `{"type":"string"}` | `[]string` | +| `{"type":"integer"}` | `[]int` | +| `{"type":"number"}` | `[]float64` | +| `{"type":"boolean"}` | `[]bool` | +| `{"type":["string","null"]}` | `[]*string` | +| missing / object / `$ref` | skipped | + +Fields whose items schema cannot be resolved remain silently skipped (same contract +as unknown primitive types in step 02). + +## Why dispatch in `setGoType` rather than trying each resolver in sequence + +`setGoType` unmarshals `s.Type` once to peek at the shape, then dispatches to the +right resolver — no wasted work. Calling `setPrimitiveType` unconditionally for an +array-typed field would force it to unmarshal `"array"`, fail the `primitiveTypes` +lookup, and return `""` before we could try `setArrayType`. Explicit dispatch avoids +that wasted unmarshal and makes the branching intent clear at a glance. + +## Why `setArrayType` does not re-check for `"array"` + +`setGoType` already gates the call: `setArrayType` is only invoked after confirming +`s.Type` is the single string `"array"`. `setArrayType` therefore focuses purely on +resolving the items type, keeping it simple. + +## Why recursive call into `setGoType` for items + +Calling `setGoType(s.Items)` rather than `setPrimitiveType(s.Items)` means that if a +future items schema is itself nullable (`["string","null"]`), it produces `[]*string` +automatically — zero extra code. It also means future steps (e.g. arrays of arrays) +chain naturally. + +## Implementation + +### Updated `setGoType` in `internal/generator/generator.go` + +```go +func setGoType(s *Schema) string { + if s.Type == nil { + return "" + } + var single string + if err := json.Unmarshal(s.Type, &single); err == nil && single == "array" { + return setArrayType(s) + } + return setPrimitiveType(s) +} +``` + +### `setArrayType` in `internal/generator/generator.go` + +Add after `setPrimitiveType` (currently the last function in the file): + +```go +// setArrayType returns "[]T" for array schemas whose items resolve to a known type. +// Returns "" for unresolvable items (object, $ref, missing, etc.). +func setArrayType(s *Schema) string { + if s.Items == nil { + return "" + } + elem := setGoType(s.Items) + if elem == "" { + return "" + } + return "[]" + elem +} +``` + +No changes to `Generate()`, `NewGenerator()`, the field-building loop, tag logic, +alphabetical sorting, or `format.Source` — all remain exactly as refactored in step 03. + +## Output + +### model/product.go + +```go +package model + +type Product struct { + Active bool `json:"active,omitempty"` + Classification string `json:"classification,omitempty"` + Description *string `json:"description,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` + Tags []string `json:"tags,omitempty"` +} +``` + +### model/category.go — unchanged from step 03 + +```go +package model + +type Category struct { + Id string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Parent *string `json:"parent,omitempty"` +} +``` + +### model/article.go + +```go +package model + +type Article struct { + Authors []string `json:"authors,omitempty"` + Id string `json:"id"` + Status string `json:"status,omitempty"` + Title string `json:"title"` +} +``` + +Fields are sorted alphabetically for deterministic output across runs. +`Authors` sorts before `Id`, which is why it appears first. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/product.go` contains `Tags []string \`json:"tags,omitempty"\`` +- [ ] `model/article.go` contains `Authors []string \`json:"authors,omitempty"\`` +- [ ] `go test ./...` passes all 8 harness subtests, including: + - `1.json` — Product with `"tags": ["sale", "new"]` (non-empty array) + - `2.json` — Product with `"tags": []` (empty array) + - `3.json` — Product with `"tags": ["precision"]` (single-element array) + - `7.json` — Article with `"authors": ["author-1", "author-2"]` (non-empty array) + - `8.json` — Article with `"authors": []` (empty array) +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 03. No new error conditions introduced. +Arrays whose `"items"` schema cannot be resolved are silently skipped — the property +is omitted from the struct, consistent with the treatment of unknown types in step 02. + +## Out of scope + +- Arrays of objects or `$ref` items — step 07+ +- Arrays of inline nested objects — step 06+ +- Nullable arrays (`["array", "null"]`) — step 06+ +- Enum fields — step 05 +- Inline nested objects — step 06 +- `$ref`, `$defs`, `oneOf`, recursive types — steps 07–10 diff --git a/docs/step-05-enums.md b/docs/step-05-enums.md new file mode 100644 index 0000000..e2bd1bf --- /dev/null +++ b/docs/step-05-enums.md @@ -0,0 +1,283 @@ +# Step 05 — Enums + +## Goal + +Extend the generator to recognise **enum-constrained fields** — properties that carry a +non-empty JSON Schema `"enum"` array of string values. Each such field is emitted as a +**named `string` type** with a **`const` block** listing the allowed values, and the +struct field references that named type instead of plain `string`. This preserves the +underlying JSON value (no custom marshal logic) while giving callers typed constants. +All other behaviour from step 04 is unchanged. + +## Inputs + +Same as step 04: `schemas/*.json`. All three schemas introduce one top-level string enum +in this step. + +### Fields in scope (string enum) + +#### Product.json + +| Property | JSON type | Enum values | Required | Go type | +|------------------|------------|-------------------------------------------------------------|----------|-------------------------| +| `classification` | `"string"` | `basic_science`, `basic-science`, `applied`, `experimental` | no | `ProductClassification` | + +#### Category.json + +| Property | JSON type | Enum values | Required | Go type | +|----------|------------|------------------------------------------------------|----------|----------------| +| `kind` | `"string"` | `basic-science`, `basic_science`, `clinical`, `other`| no | `CategoryKind` | + +#### Article.json + +| Property | JSON type | Enum values | Required | Go type | +|----------|------------|-----------------------------------|----------|-----------------| +| `status` | `"string"` | `draft`, `published`, `archived` | no | `ArticleStatus` | + +`Product.dimensions.unit` (`["cm","inch"]`) lives inside an inline nested object, which +the generator does not yet descend into, so it is **out of scope** until step 06. + +## Enum type detection + +A schema node is an enum when its `"enum"` array is non-empty. The generated type name is +`` (e.g. `Product` + `classification` → +`ProductClassification`). Each enum value becomes a constant named +`` whose value is the original JSON string. + +| Enum field | Generated type | Sample constant | +|---------------------------|-------------------------|------------------------------------------| +| `Product.classification` | `ProductClassification` | `ProductClassificationApplied = "applied"` | +| `Category.kind` | `CategoryKind` | `CategoryKindClinical = "clinical"` | +| `Article.status` | `ArticleStatus` | `ArticleStatusDraft = "draft"` | + +Enum detection takes precedence over the primitive path: even though these fields also +declare `"type": "string"`, the presence of `"enum"` routes them to the enum resolver. + +## Constant name collisions — numeric suffix + +`toPascalCase` strips both `_` and `-`, so `basic_science` and `basic-science` both map to +the identifier `BasicScience`. Two enums hit this deliberately: + +- `Product.classification` contains both `basic_science` and `basic-science`. +- `Category.kind` contains both `basic-science` and `basic_science`. + +Every value must get its own constant, so the generator emits the **first** value with the +clean identifier and appends a **numeric suffix** (`2`, `3`, …) to each later value that +would reproduce an already-used name — e.g. `BasicScience` and `BasicScience2`. No value is +dropped. First-in-array order decides which value keeps the clean name, which keeps output +deterministic. + +## Why enum handling lives in `Generate()`, not `setGoType` + +`setGoType` returns a single type string for a field and has no access to the parent +schema title, the property name, or an output buffer. An enum needs all three: it must +emit a **top-level `type` declaration and `const` block** alongside the struct, and name +them after `Title + property`. So the enum branch sits in the `Generate()` field loop, +where that context exists, and delegates to `setEnumType`. Non-enum fields keep flowing +through `setGoType` exactly as before. + +## Why a named `string` type rather than a custom Marshal/Unmarshal + +A `type ProductClassification string` round-trips through `encoding/json` with zero extra +code — the underlying kind is `string`, so marshalling and unmarshalling behave like a +plain string while the type documents intent and the constants provide safe values. This +matches the step-00 design decision and avoids the complexity reserved for `oneOf` +discriminated unions (step 09). + +## Implementation + +### `setEnumType` in `internal/generator/generator.go` + +Add after `setArrayType` (currently the last resolver in the file): + +```go +// setEnumType emits a named string type and a const block for a string enum into decls, +// and returns the generated type name to use as the field's Go type. Non-string enum +// values are skipped; identifier collisions (after PascalCasing) are disambiguated with a +// numeric suffix so every value gets its own constant. +func setEnumType(decls *bytes.Buffer, title, property string, values []json.RawMessage) string { + typeName := title + toPascalCase(property) + var consts bytes.Buffer + seen := make(map[string]bool) + for _, raw := range values { + var v string + if err := json.Unmarshal(raw, &v); err != nil { + continue // non-string enum value — out of scope for step 05 + } + name := typeName + toPascalCase(v) + // Disambiguate identifier collisions (e.g. basic_science vs basic-science both + // PascalCase to BasicScience) with a numeric suffix so every value gets a constant. + base := name + for i := 2; seen[name]; i++ { + name = fmt.Sprintf("%s%d", base, i) + } + seen[name] = true + fmt.Fprintf(&consts, "\t%s %s = %q\n", name, typeName, v) + } + fmt.Fprintf(decls, "type %s string\n\nconst (\n%s)\n", typeName, consts.String()) + return typeName +} +``` + +### `Generate()` field loop in `internal/generator/generator.go` + +Introduce a `decls` buffer, branch on `Enum`, and append the declarations after the +struct body. Everything else (required-field map, alphabetical sorting, tag logic, +`format.Source`) is unchanged from step 04: + +```go +var fields bytes.Buffer +var decls bytes.Buffer +for _, property := range propertyNames { + prop := s.Properties[property] + + var goType string + if len(prop.Enum) > 0 { + goType = setEnumType(&decls, s.Title, property, prop.Enum) + } else { + goType = setGoType(prop) + } + if goType == "" { + continue + } + + tag := property + if !requiredFields[property] { + tag += ",omitempty" + } + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(property), goType, tag) +} + +if fields.Len() > 0 { + fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", s.Title, fields.String()) + if decls.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", decls.String()) + } + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("formatting source for %q: %w", s.Title, err) + } + + filename := strings.ToLower(s.Title) + ".go" + schemasMap[filename] = src +} +``` + +`format.Source` re-aligns both the struct columns and the const block, so the manual +spacing above does not need to be precise. No changes to `setGoType`, `setArrayType`, +`setPrimitiveType`, `NewGenerator`, or the `Schema` struct (`Enum []json.RawMessage` +already exists). + +## Output + +### model/product.go + +```go +package model + +type Product struct { + Active bool `json:"active,omitempty"` + Classification ProductClassification `json:"classification,omitempty"` + Description *string `json:"description,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type ProductClassification string + +const ( + ProductClassificationBasicScience ProductClassification = "basic_science" + ProductClassificationBasicScience2 ProductClassification = "basic-science" + ProductClassificationApplied ProductClassification = "applied" + ProductClassificationExperimental ProductClassification = "experimental" +) +``` + +`basic-science` collides with `basic_science` (which appears first), so it is emitted as +`ProductClassificationBasicScience2`. + +### model/category.go + +```go +package model + +type Category struct { + Id string `json:"id,omitempty"` + Kind CategoryKind `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Parent *string `json:"parent,omitempty"` +} + +type CategoryKind string + +const ( + CategoryKindBasicScience CategoryKind = "basic-science" + CategoryKindBasicScience2 CategoryKind = "basic_science" + CategoryKindClinical CategoryKind = "clinical" + CategoryKindOther CategoryKind = "other" +) +``` + +`basic_science` collides with `basic-science` (which appears first), so it is emitted as +`CategoryKindBasicScience2`. + +### model/article.go + +```go +package model + +type Article struct { + Authors []string `json:"authors,omitempty"` + Id string `json:"id"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` +} + +type ArticleStatus string + +const ( + ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusPublished ArticleStatus = "published" + ArticleStatusArchived ArticleStatus = "archived" +) +``` + +Fields are sorted alphabetically for deterministic output across runs. Enum constants are +emitted in schema array order. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/product.go` declares `type ProductClassification string` with its const block and `Classification ProductClassification \`json:"classification,omitempty"\`` +- [ ] `model/category.go` declares `type CategoryKind string`; `model/article.go` declares `type ArticleStatus string` +- [ ] Every enum value emits a unique constant; collisions are disambiguated with a numeric suffix (e.g. `BasicScience`, `BasicScience2`) — no duplicate identifiers +- [ ] `go test ./...` passes all 8 harness subtests, including enum payloads: + - `1.json` — Product with `"classification": "basic_science"` + - `6.json` — Category with `"kind": "other"` +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 04. No new error conditions introduced. Enum values that +are not JSON strings are silently skipped from the const block (consistent with the +silent-skip treatment of unknown types). A field with an empty/absent `"enum"` is handled +by the existing primitive/array paths exactly as before. + +## Out of scope + +- `Product.dimensions.unit` and any enums nested inside inline objects — step 06 +- Non-string enums (integer/number) and nullable enums (`["string","null"]` + `enum`) +- `const` discriminator fields (`format: "richtext"`) — step 09 +- Inline nested objects — step 06 +- `$ref`, `$defs`, `oneOf`, recursive types — steps 07–10 +``` diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 466f4ca..2a0d713 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -11,6 +11,10 @@ import ( "strings" ) +const ( + ARRAY_TYPE = "array" +) + // Schema represents a JSON Schema node. type Schema struct { Title string `json:"title"` // Title is required for named types. @@ -74,44 +78,55 @@ func NewGenerator(dir string) (*Generator, error) { func (g *Generator) Generate() (map[string][]byte, error) { schemasMap := make(map[string][]byte, len(g.schemas)) - for _, s := range g.schemas { + for _, mainSchema := range g.schemas { var buf bytes.Buffer - requiredFields := make(map[string]bool, len(s.Required)) - for _, r := range s.Required { + requiredFields := make(map[string]bool, len(mainSchema.Required)) + for _, r := range mainSchema.Required { requiredFields[r] = true } // TODO, i think i can avoid this loop and sorting by adding maybe a flag to the schema // that tells me if the property was already processed that way i can have idempotency - propertyNames := make([]string, 0, len(s.Properties)) - for propertyName := range s.Properties { + propertyNames := make([]string, 0, len(mainSchema.Properties)) + for propertyName := range mainSchema.Properties { propertyNames = append(propertyNames, propertyName) } sort.Strings(propertyNames) var fields bytes.Buffer - for _, property := range propertyNames { - goType := setGoType(s.Properties[property]) + var decls bytes.Buffer + for _, propertyName := range propertyNames { + property := mainSchema.Properties[propertyName] + + var goType string + if len(property.Enum) > 0 { + goType = setEnumType(&decls, mainSchema.Title, propertyName, property.Enum) + } else { + goType = setGoType(property) + } if goType == "" { continue } - tag := property - if !requiredFields[property] { + tag := propertyName + if !requiredFields[propertyName] { tag += ",omitempty" } - fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(property), goType, tag) + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(propertyName), goType, tag) } if fields.Len() > 0 { - fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", s.Title, fields.String()) + fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", mainSchema.Title, fields.String()) + if decls.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", decls.String()) + } src, err := format.Source(buf.Bytes()) if err != nil { - return nil, fmt.Errorf("formatting source for %q: %w", s.Title, err) + return nil, fmt.Errorf("formatting source for %q: %w", mainSchema.Title, err) } - filename := strings.ToLower(s.Title) + ".go" + filename := strings.ToLower(mainSchema.Title) + ".go" schemasMap[filename] = src } } @@ -126,10 +141,52 @@ func setGoType(s *Schema) string { if s.Type == nil { return "" } - + var single string + if err := json.Unmarshal(s.Type, &single); err == nil && single == ARRAY_TYPE { + return setArrayType(s) + } return setPrimitiveType(s) } +// setArrayType returns "[]T" for array schemas whose items resolve to a known type. +// Returns "" for unresolvable items (object, $ref, missing, etc.). +func setArrayType(s *Schema) string { + if s.Items == nil { + return "" + } + elem := setGoType(s.Items) + if elem == "" { + return "" + } + return "[]" + elem +} + +// setEnumType emits a named string type and a const block for a string enum into decls, +// and returns the generated type name to use as the field's Go type. Non-string enum +// values are skipped; identifier collisions (after PascalCasing) are disambiguated with a +// numeric suffix so every value gets its own constant. +func setEnumType(decls *bytes.Buffer, schemaName, property string, values []json.RawMessage) string { + typeName := schemaName + toPascalCase(property) + var consts bytes.Buffer + seen := make(map[string]bool) + for _, raw := range values { + var v string + if err := json.Unmarshal(raw, &v); err != nil { + continue // non-string enum value — out of scope for step 05 + } + name := typeName + toPascalCase(v) + base := name + for i := 2; seen[name]; i++ { + name = fmt.Sprintf("%s%d", base, i) + } + seen[name] = true + fmt.Fprintf(&consts, "\t%s %s = %q\n", name, typeName, v) + } + + fmt.Fprintf(decls, "type %s string\n\nconst (\n%s)\n", typeName, consts.String()) + return typeName +} + // setPrimitiveType returns the Go type string for a schema node, or "" if the type cannot // be mapped to a primitive (array, object, multi-type beyond nullable, etc.). // Handles both single-string types ("string") and nullable pairs (["string","null"]). diff --git a/model/article.go b/model/article.go index 0d96153..1c82c5a 100644 --- a/model/article.go +++ b/model/article.go @@ -1,7 +1,16 @@ package model type Article struct { - Id string `json:"id"` - Status string `json:"status,omitempty"` - Title string `json:"title"` + Authors []string `json:"authors,omitempty"` + Id string `json:"id"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` } + +type ArticleStatus string + +const ( + ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusPublished ArticleStatus = "published" + ArticleStatusArchived ArticleStatus = "archived" +) diff --git a/model/category.go b/model/category.go index 2c2cf69..84ef2e1 100644 --- a/model/category.go +++ b/model/category.go @@ -1,8 +1,17 @@ package model type Category struct { - Id string `json:"id,omitempty"` - Kind string `json:"kind,omitempty"` - Name string `json:"name,omitempty"` - Parent *string `json:"parent,omitempty"` + Id string `json:"id,omitempty"` + Kind CategoryKind `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Parent *string `json:"parent,omitempty"` } + +type CategoryKind string + +const ( + CategoryKindBasicScience CategoryKind = "basic-science" + CategoryKindBasicScience2 CategoryKind = "basic_science" + CategoryKindClinical CategoryKind = "clinical" + CategoryKindOther CategoryKind = "other" +) diff --git a/model/product.go b/model/product.go index 99d8b24..48c99e4 100644 --- a/model/product.go +++ b/model/product.go @@ -1,11 +1,21 @@ package model type Product struct { - Active bool `json:"active,omitempty"` - Classification string `json:"classification,omitempty"` - Description *string `json:"description,omitempty"` - Id string `json:"id"` - Name string `json:"name"` - Price float64 `json:"price"` - Quantity int `json:"quantity,omitempty"` + Active bool `json:"active,omitempty"` + Classification ProductClassification `json:"classification,omitempty"` + Description *string `json:"description,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` + Tags []string `json:"tags,omitempty"` } + +type ProductClassification string + +const ( + ProductClassificationBasicScience ProductClassification = "basic_science" + ProductClassificationBasicScience2 ProductClassification = "basic-science" + ProductClassificationApplied ProductClassification = "applied" + ProductClassificationExperimental ProductClassification = "experimental" +) From a07c75fc5e074894a9041ab6f36d53df37180614 Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Sat, 27 Jun 2026 11:35:26 +0200 Subject: [PATCH 5/9] feat: add step 06 and 07 inline nested objects and cross-schema references --- Makefile | 3 + docs/step-06-inline-nested-objects.md | 349 ++++++++++++++++++++++++++ docs/step-07-cross-schema-ref.md | 199 +++++++++++++++ internal/generator/generator.go | 143 ++++++++--- model/article.go | 21 +- model/product.go | 14 ++ 6 files changed, 688 insertions(+), 41 deletions(-) create mode 100644 docs/step-06-inline-nested-objects.md create mode 100644 docs/step-07-cross-schema-ref.md diff --git a/Makefile b/Makefile index 78f4612..09efeca 100644 --- a/Makefile +++ b/Makefile @@ -6,3 +6,6 @@ build: test: go clean -testcache && go test ./... + +generate: + ./bin/generator diff --git a/docs/step-06-inline-nested-objects.md b/docs/step-06-inline-nested-objects.md new file mode 100644 index 0000000..f849d17 --- /dev/null +++ b/docs/step-06-inline-nested-objects.md @@ -0,0 +1,349 @@ +# Step 06 — Inline Nested Objects + +## Goal + +Extend the generator to descend into **inline nested objects** — properties whose schema +is itself an object (`"type": "object"` or `"type": ["object","null"]` with a +`"properties"` map). Each inline object is emitted as a **named sub-struct**, and the +field references that struct by name. Nullable objects (`["object","null"]`) become +**pointer fields** (`*T`). Enums that live *inside* an inline object — deferred from +step 05 — are now resolved too (e.g. `Product.dimensions.unit`). All other behaviour from +step 05 is unchanged. + +## Inputs + +Same as step 05: `schemas/*.json`. Two schemas introduce inline objects; `Category` has +none. + +### Fields in scope (inline object) + +#### Product.json + +| Property | JSON type | Nested object shape | Required | Go type | +|--------------|------------|-----------------------------------------------------------------------|----------|--------------------| +| `dimensions` | `"object"` | `width: number`, `height: number`, `unit: string enum ["cm","inch"]` | no | `ProductDimensions` | + +#### Article.json + +| Property | JSON type | Nested object shape | Required | Go type | +|--------------|----------------------|----------------------------------------------|----------|--------------------| +| `dimensions` | `"object"` | `width: integer`, `height: integer` | no | `ArticleDimensions` | +| `metadata` | `["object","null"]` | `wordCount: integer`, `readingTime: number` | no | `*ArticleMetadata` | + +None of the inline objects declare their own `required` array, so every sub-field is +optional and carries `,omitempty`. + +`Article.category` (`$ref`) and `Article.content` (`oneOf`) are **not** inline objects — +they carry no `"type"` and are left for steps 07 and 09. + +## Nested object type detection and naming + +A property is an inline object when its `"type"` is the single string `"object"` or a +two-element array containing `"object"` and `"null"`. The generated type name is +``: + +| Inline object | Generated type | Nullable? | Field type | +|----------------------|---------------------|-----------|---------------------| +| `Product.dimensions` | `ProductDimensions` | no | `ProductDimensions` | +| `Article.dimensions` | `ArticleDimensions` | no | `ArticleDimensions` | +| `Article.metadata` | `ArticleMetadata` | yes | `*ArticleMetadata` | + +The containing type name is used as the prefix (not just the schema title) so the scheme +composes through nesting. At the top level the containing type name *is* the schema title, +so existing names are unchanged. + +### Nested enums (deferred from step 05) + +Because sub-fields flow through the same resolver as top-level fields, an enum inside an +inline object is now named off its containing struct: + +| Nested enum field | Generated type | Sample constant | +|----------------------------|-------------------------|---------------------------------------| +| `Product.dimensions.unit` | `ProductDimensionsUnit` | `ProductDimensionsUnitCm = "cm"` | + +## Nullable objects → pointer + +`["object","null"]` resolves to `*T`, matching the step-03 nullable-primitive rule +(`*string`, `*int`). `encoding/json` decodes a JSON `null` into a nil pointer and an object +into an allocated struct, so `"metadata": null` (8.json) and a populated `metadata` +(7.json) both round-trip with no custom logic. + +## Why object handling lives in the field loop, not `setGoType` + +`setGoType` returns a single type string and has no access to the parent type name or an +output buffer. An inline object — like an enum — must emit a **top-level `type` struct +declaration** named after its parent and append it alongside the main struct. So object +detection sits in the field loop next to the enum branch and delegates to `setObjectType`. +To avoid duplicating the field loop between the top-level struct and nested ones, the loop +itself is extracted into a shared `genFields` helper that both paths call. + +## Implementation + +All changes are in `internal/generator/generator.go`. No changes to the `Schema` struct, +`NewGenerator`, `setGoType`, `setArrayType`, `setPrimitiveType`, or `setEnumType` +(`setEnumType` already takes the prefix as its `schemaName` argument). + +### `objectType` — detect inline objects + +```go +// objectType reports whether s is an inline object and whether it is nullable +// (type ["object","null"]). Single "object" → (true,false); ["object","null"] → +// (true,true); anything else → (false,false). $ref and oneOf nodes carry no "type" +// and so are never treated as inline objects (handled in later steps). +func objectType(s *Schema) (isObject, nullable bool) { + var single string + if err := json.Unmarshal(s.Type, &single); err == nil { + return single == "object", false + } + var multi []string + if err := json.Unmarshal(s.Type, &multi); err == nil && len(multi) == 2 { + var hasObject, hasNull bool + for _, t := range multi { + switch t { + case "object": + hasObject = true + case "null": + hasNull = true + } + } + return hasObject && hasNull, hasObject && hasNull + } + return false, false +} +``` + +### `genFields` — shared, recursive field loop + +Extracted from the current `Generate()` body. Builds the struct body for `typeName`, +emitting any nested enum/object declarations into `decls`. Enums and inline objects are +named `typeName + PascalCase(property)`. + +```go +// genFields builds the struct body for a struct named typeName from s.Properties, +// emitting any nested enum/object type declarations into decls. Properties are sorted +// for deterministic output. +func genFields(decls *bytes.Buffer, typeName string, s *Schema) string { + requiredFields := make(map[string]bool, len(s.Required)) + for _, r := range s.Required { + requiredFields[r] = true + } + + propertyNames := make([]string, 0, len(s.Properties)) + for propertyName := range s.Properties { + propertyNames = append(propertyNames, propertyName) + } + sort.Strings(propertyNames) + + var fields bytes.Buffer + for _, propertyName := range propertyNames { + property := s.Properties[propertyName] + + var goType string + switch { + case len(property.Enum) > 0: + goType = setEnumType(decls, typeName, propertyName, property.Enum) + default: + if isObj, nullable := objectType(property); isObj { + goType = setObjectType(decls, typeName+toPascalCase(propertyName), property) + if nullable && goType != "" { + goType = "*" + goType + } + } else { + goType = setGoType(property) + } + } + if goType == "" { + continue + } + + tag := propertyName + if !requiredFields[propertyName] { + tag += ",omitempty" + } + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(propertyName), goType, tag) + } + return fields.String() +} +``` + +### `setObjectType` — emit a named sub-struct + +```go +// setObjectType emits a named struct declaration for an inline object into decls and +// returns the type name. Nested enums/objects are emitted (recursively) before the +// struct itself. An object with no resolvable fields emits nothing and returns "" so the +// caller skips the field. +func setObjectType(decls *bytes.Buffer, typeName string, s *Schema) string { + body := genFields(decls, typeName, s) + if body == "" { + return "" + } + fmt.Fprintf(decls, "type %s struct {\n%s}\n\n", typeName, body) + return typeName +} +``` + +### `Generate()` — call the shared helper for the top-level struct + +The inline loop is replaced by a single `genFields` call; everything else (per-schema +loop, `format.Source`, filename) is unchanged. + +```go +func (g *Generator) Generate() (map[string][]byte, error) { + schemasMap := make(map[string][]byte, len(g.schemas)) + + for _, mainSchema := range g.schemas { + var buf bytes.Buffer + var decls bytes.Buffer + + body := genFields(&decls, mainSchema.Title, mainSchema) + if body == "" { + continue + } + + fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", mainSchema.Title, body) + if decls.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", decls.String()) + } + + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("formatting source for %q: %w", mainSchema.Title, err) + } + + filename := strings.ToLower(mainSchema.Title) + ".go" + schemasMap[filename] = src + } + + return schemasMap, nil +} +``` + +`format.Source` re-aligns every struct's columns and const blocks, so manual spacing need +not be precise. Declarations are appended in the order their properties are visited +(alphabetical), which keeps output byte-identical across runs. + +## Output + +### model/product.go + +```go +package model + +type Product struct { + Active bool `json:"active,omitempty"` + Classification ProductClassification `json:"classification,omitempty"` + Description *string `json:"description,omitempty"` + Dimensions ProductDimensions `json:"dimensions,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + Quantity int `json:"quantity,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type ProductClassification string + +const ( + ProductClassificationBasicScience ProductClassification = "basic_science" + ProductClassificationBasicScience2 ProductClassification = "basic-science" + ProductClassificationApplied ProductClassification = "applied" + ProductClassificationExperimental ProductClassification = "experimental" +) + +type ProductDimensionsUnit string + +const ( + ProductDimensionsUnitCm ProductDimensionsUnit = "cm" + ProductDimensionsUnitInch ProductDimensionsUnit = "inch" +) + +type ProductDimensions struct { + Height float64 `json:"height,omitempty"` + Unit ProductDimensionsUnit `json:"unit,omitempty"` + Width float64 `json:"width,omitempty"` +} +``` + +Declaration order follows alphabetical property visiting: `classification` (2nd) emits +`ProductClassification`; `dimensions` (4th) emits `ProductDimensionsUnit` (from its `unit` +sub-field, emitted before the struct) then `ProductDimensions`. + +### model/article.go + +```go +package model + +type Article struct { + Authors []string `json:"authors,omitempty"` + Dimensions ArticleDimensions `json:"dimensions,omitempty"` + Id string `json:"id"` + Metadata *ArticleMetadata `json:"metadata,omitempty"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` +} + +type ArticleDimensions struct { + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +type ArticleMetadata struct { + ReadingTime float64 `json:"readingTime,omitempty"` + WordCount int `json:"wordCount,omitempty"` +} + +type ArticleStatus string + +const ( + ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusPublished ArticleStatus = "published" + ArticleStatusArchived ArticleStatus = "archived" +) +``` + +`category` (`$ref`) and `content` (`oneOf`) still resolve to `""` and are skipped. Decls +appear in property-visit order: `dimensions` → `ArticleDimensions`, `metadata` → +`ArticleMetadata`, `status` → `ArticleStatus`. + +### model/category.go + +Unchanged from step 05 — `Category` has no inline objects. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/product.go` declares `type ProductDimensions struct { … }` with `Width`, + `Height`, `Unit` and `Dimensions ProductDimensions \`json:"dimensions,omitempty"\`` +- [ ] `Product.dimensions.unit` emits `type ProductDimensionsUnit string` with its const + block (`Cm`, `Inch`) — the step-05 deferral is resolved +- [ ] `model/article.go` declares `type ArticleDimensions struct { … }` (int fields) and + `type ArticleMetadata struct { … }`, with `Metadata *ArticleMetadata` (pointer, + because the schema type is `["object","null"]`) +- [ ] `go test ./...` passes all 8 harness subtests, including the nested-object payloads: + - `3.json` — Product with `"dimensions": {"width":10.5,"height":3.25,"unit":"cm"}` + - `7.json` — Article with populated `dimensions` and `metadata` + - `8.json` — Article with `"metadata": null` (decodes to a nil `*ArticleMetadata`) +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 05. No new error conditions. An inline object with no +resolvable fields emits no declaration and the field is skipped (consistent with the +silent-skip treatment of unresolvable types). Non-string values inside a nested enum are +skipped exactly as at the top level. + +## Out of scope + +- `Article.category` (`$ref` → another schema) — step 07 +- `Article` `$defs` (RichText, PlainText, RichTextNode) — step 08 +- `Article.content` (`oneOf` discriminated union) — step 09 +- Recursive types (`RichTextNode.children`) — step 10 +- `const` discriminator fields (`format: "richtext"`) — step 09 diff --git a/docs/step-07-cross-schema-ref.md b/docs/step-07-cross-schema-ref.md new file mode 100644 index 0000000..b98c5e3 --- /dev/null +++ b/docs/step-07-cross-schema-ref.md @@ -0,0 +1,199 @@ +# Step 07 — Cross-schema `$ref` + +## Goal + +Extend the generator to resolve **cross-schema `$ref`** nodes — properties whose schema is a +reference to *another schema file* (`{"$ref": "Category.json"}`). The reference is resolved at +generation time into a **direct Go type reference**: `Article.category` becomes a field of type +`Category`, the type already generated from `Category.json`. Resolution is purely string-based +(strip the path and `.json` suffix) and emits the reference unconditionally — no lookup, no +existence check. Local references (`#/$defs/...`) are left untouched (`""`) for later steps. +All other behaviour from step 06 is unchanged. + +## Inputs + +Same as step 06: `schemas/*.json`. Only `Article` carries a cross-schema `$ref` at the property +level; `Product` and `Category` have none. + +### Fields in scope (cross-schema `$ref`) + +#### Article.json + +| Property | Schema node | Resolves to | Required | Go type | +|------------|----------------------------|-------------------|----------|------------| +| `category` | `{"$ref":"Category.json"}` | `Category` schema | no | `Category` | + +`category` is not in `Article.required`, so it carries `,omitempty`. The `$ref` node has no +`"type"` and no `"null"`, so it is **not nullable** and the field is a value type (no pointer) — +consistent with step 06's treatment of non-nullable inline objects (`Dimensions ArticleDimensions`). + +`Article.content` (`oneOf`) and the `$ref`s inside `$defs` (`#/$defs/RichTextNode`) are **not** +cross-schema file references and are left for steps 08–10. + +## `$ref` detection and resolution + +A property is a cross-schema `$ref` when its `"$ref"` value is non-empty and does **not** start +with `#`. Resolution strips the path and the `.json` suffix from the ref value: + +| `$ref` value | `filepath.Base` | `TrimSuffix(".json")` | Go type | +|----------------------|-----------------|-----------------------|------------| +| `"Category.json"` | `Category.json` | `Category` | `Category` | +| `"#/$defs/RichText"` | — | — (local, returns "") | (skipped) | + +This relies on the project convention that a ref's file base name equals the referenced schema's +title — which holds for `Article.json` / `Category.json` / `Product.json`. The referenced type is +emitted from its own schema (`Category` lives in `model/category.go`), so the `$ref` only needs to +name it. + +### No existence guard (deliberate) + +`format.Source` only *formats* the generated source — it does not type-check it — so the reference +is emitted whether or not a `Category` type actually gets generated. In normal use the referenced +schema is present in `schemas/` and is generated, so the reference resolves. If it was never added, +`model/` won't compile (`undefined: Category`) and the user fixes it manually. This keeps the code +simple: no set of known titles, and no extra parameter threaded through `generateFields` / +`setObjectType`. + +Declaration and file order are a non-issue regardless — all generated types live in the same +`model` package, and Go requires no forward declaration, so `article.go` referencing `Category` +(defined in `category.go`) compiles independent of generation order. + +## Why ref handling lives in the field loop, not `setGoType` + +A `$ref` node carries no `"type"`, so today both `objectType` and `setGoType` return `""` and the +field is silently dropped (this is why the current `model/article.go` has no `Category` field). +Like the enum and inline-object branches added in step 06, `$ref` resolution is a dedicated branch +in the field loop. Unlike those, it needs no output buffer and no parent type name — resolution is +a pure string transform — so it is a standalone helper called from the `switch`. + +## Implementation + +All changes are in `internal/generator/generator.go`. No changes to the `Schema` struct, +`NewGenerator`, `Generate`, `setGoType`, `setArrayType`, `setPrimitiveType`, `setEnumType`, +`objectType`, or the signatures of `generateFields` / `setObjectType`. + +### `resolveRef` — resolve a cross-schema `$ref` to a type name + +```go +// resolveRef maps a cross-schema $ref like "Category.json" to its Go type name by stripping +// the path and ".json" suffix. Local refs ("#/...") are out of scope (steps 08+) and return "". +// There is no existence check: if the referenced schema is never generated, the emitted +// reference won't compile and must be fixed manually. +func resolveRef(ref string) string { + if ref == "" || strings.HasPrefix(ref, "#") { + return "" + } + return strings.TrimSuffix(filepath.Base(ref), ".json") +} +``` + +### `generateFields` — add the `$ref` branch + +A new `case` is added to the field-loop `switch`, between the enum case and the object/primitive +default. Everything else in the loop is unchanged. + +```go +switch { +case len(property.Enum) > 0: + goType = setEnumType(decls, typeName, name, property.Enum) +case property.Ref != "": + goType = resolveRef(property.Ref) +default: + if isObj, nullable := objectType(property); isObj { + goType = setObjectType(decls, typeName+toPascalCase(name), property) + if nullable && goType != "" { + goType = "*" + goType + } + } else { + goType = setGoType(property) + } +} +``` + +A local ref resolves to `""` and the field is skipped (consistent with the silent-skip treatment +of unresolvable types). A cross-schema ref resolves to the referenced type name and the field is +emitted with an `,omitempty` tag (it is optional). + +## Output + +### model/article.go + +```go +package model + +type Article struct { + Authors []string `json:"authors,omitempty"` + Category Category `json:"category,omitempty"` + Dimensions ArticleDimensions `json:"dimensions,omitempty"` + Id string `json:"id"` + Metadata *ArticleMetadata `json:"metadata,omitempty"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` +} + +type ArticleDimensions struct { + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +type ArticleMetadata struct { + ReadingTime float64 `json:"readingTime,omitempty"` + WordCount int `json:"wordCount,omitempty"` +} + +type ArticleStatus string + +const ( + ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusPublished ArticleStatus = "published" + ArticleStatusArchived ArticleStatus = "archived" +) +``` + +Alphabetical property visiting puts `category` (2nd) between `authors` and `dimensions`. The new +`Category` field is the only change from step 06; `content` (`oneOf`, no `$ref` at the property +level) still resolves to `""` and is skipped, and the `ArticleDimensions` / `ArticleMetadata` / +`ArticleStatus` declarations are unchanged. + +### model/category.go + +Unchanged from step 05 — `Category` is generated from its own schema; the `$ref` in `Article` +simply points at it. + +### model/product.go + +Unchanged — `Product` has no cross-schema references. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/article.go` declares `Category Category \`json:"category,omitempty"\`` (value type, + because the `$ref` is not nullable) +- [ ] `model/category.go` and `model/product.go` are unchanged +- [ ] `go test ./...` passes all 8 harness subtests, including the cross-ref payloads: + - `7.json` — Article with a populated `"category": {"id":"cat-1","name":"Anatomy", + "kind":"basic-science","parent":null}` that round-trips into `Category` + - `8.json` — Article with no `category` (the value-type field stays zero-valued) +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 06. Local refs (`#/...`) resolve to `""` and the field is skipped +(no new error conditions). Cross-schema refs are emitted as-is with no existence check: a ref to a +schema that is never generated yields a reference to an undefined type, so `model/` won't compile +until the missing schema is added or the field is removed by hand. This is an accepted trade-off +for simplicity, not a generator error. + +## Out of scope + +- `Article` `$defs` (RichText, PlainText, RichTextNode) and local `#/$defs/...` refs — step 08 +- `Article.content` (`oneOf` discriminated union) — step 09 +- Recursive types (`RichTextNode.children`) — step 10 +- `const` discriminator fields (`format: "richtext"`) — step 09 diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 2a0d713..c4de88c 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -80,58 +80,116 @@ func (g *Generator) Generate() (map[string][]byte, error) { for _, mainSchema := range g.schemas { var buf bytes.Buffer + var decls bytes.Buffer - requiredFields := make(map[string]bool, len(mainSchema.Required)) - for _, r := range mainSchema.Required { - requiredFields[r] = true + body := generateFields(&decls, mainSchema.Title, mainSchema) + if body == "" { + continue } - // TODO, i think i can avoid this loop and sorting by adding maybe a flag to the schema - // that tells me if the property was already processed that way i can have idempotency - propertyNames := make([]string, 0, len(mainSchema.Properties)) - for propertyName := range mainSchema.Properties { - propertyNames = append(propertyNames, propertyName) + fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", mainSchema.Title, body) + if decls.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", decls.String()) } - sort.Strings(propertyNames) - var fields bytes.Buffer - var decls bytes.Buffer - for _, propertyName := range propertyNames { - property := mainSchema.Properties[propertyName] + src, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("formatting source for %q: %w", mainSchema.Title, err) + } - var goType string - if len(property.Enum) > 0 { - goType = setEnumType(&decls, mainSchema.Title, propertyName, property.Enum) - } else { - goType = setGoType(property) - } - if goType == "" { - continue - } + filename := strings.ToLower(mainSchema.Title) + ".go" + schemasMap[filename] = src + } - tag := propertyName - if !requiredFields[propertyName] { - tag += ",omitempty" + return schemasMap, nil +} + +// objectType reports whether s is an inline object and whether it is nullable +// (type ["object","null"]). Single "object" → (true,false); ["object","null"] → +// (true,true); anything else → (false,false). $ref and oneOf nodes carry no "type" +// and so are never treated as inline objects (handled in later steps). +func objectType(s *Schema) (isObject, nullable bool) { + var single string + if err := json.Unmarshal(s.Type, &single); err == nil { + return single == "object", false + } + var multi []string + if err := json.Unmarshal(s.Type, &multi); err == nil && len(multi) == 2 { + var hasObject, hasNull bool + for _, t := range multi { + switch t { + case "object": + hasObject = true + case "null": + hasNull = true } - fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(propertyName), goType, tag) } + // it looks redundant to return the same value twice, but the first return is for isObject and the second is for nullable + // but we are evaluating this scenario: ["object", "null"] which means it is an object and it is nullable. + return hasObject && hasNull, hasObject && hasNull + } + return false, false +} - if fields.Len() > 0 { - fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", mainSchema.Title, fields.String()) - if decls.Len() > 0 { - fmt.Fprintf(&buf, "\n%s", decls.String()) - } - src, err := format.Source(buf.Bytes()) - if err != nil { - return nil, fmt.Errorf("formatting source for %q: %w", mainSchema.Title, err) +// generateFields builds the struct body for a struct named typeName from s.Properties, +// emitting any nested enum/object type declarations into decls. Properties are sorted +// for deterministic output. +func generateFields(decls *bytes.Buffer, typeName string, s *Schema) string { + requiredFields := make(map[string]bool, len(s.Required)) + for _, r := range s.Required { + requiredFields[r] = true + } + + propertiesNames := make([]string, 0, len(s.Properties)) + for name := range s.Properties { + propertiesNames = append(propertiesNames, name) + } + sort.Strings(propertiesNames) + + var fields bytes.Buffer + for _, name := range propertiesNames { + property := s.Properties[name] + + var goType string + switch { + case len(property.Enum) > 0: + goType = setEnumType(decls, typeName, name, property.Enum) + case property.Ref != "": + goType = resolveRef(property.Ref) + default: + if isObj, nullable := objectType(property); isObj { + goType = setObjectType(decls, typeName+toPascalCase(name), property) + if nullable && goType != "" { + goType = "*" + goType + } + } else { + goType = setGoType(property) } + } + if goType == "" { + continue + } - filename := strings.ToLower(mainSchema.Title) + ".go" - schemasMap[filename] = src + tag := name + if !requiredFields[name] { + tag += ",omitempty" } + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(name), goType, tag) } + return fields.String() +} - return schemasMap, nil +// setObjectType emits a named struct declaration for an inline object into decls and +// returns the type name. Nested enums/objects are emitted (recursively) before the +// struct itself. An object with no resolvable fields emits nothing and returns "" so the +// caller skips the field. +func setObjectType(decls *bytes.Buffer, typeName string, s *Schema) string { + body := generateFields(decls, typeName, s) + if body == "" { + return "" + } + fmt.Fprintf(decls, "type %s struct {\n%s}\n\n", typeName, body) + return typeName } // setGoType returns the Go type string for a schema node, or "" if the type cannot @@ -187,6 +245,17 @@ func setEnumType(decls *bytes.Buffer, schemaName, property string, values []json return typeName } +// resolveRef maps a cross-schema $ref like "Category.json" to its Go type name by stripping +// the path and ".json" suffix. Local refs ("#/...") are out of scope (steps 08+) and return "". +// There is no existence check: if the referenced schema is never generated, the emitted +// reference won't compile and must be fixed manually. +func resolveRef(ref string) string { + if ref == "" || strings.HasPrefix(ref, "#") { + return "" + } + return strings.TrimSuffix(filepath.Base(ref), ".json") +} + // setPrimitiveType returns the Go type string for a schema node, or "" if the type cannot // be mapped to a primitive (array, object, multi-type beyond nullable, etc.). // Handles both single-string types ("string") and nullable pairs (["string","null"]). diff --git a/model/article.go b/model/article.go index 1c82c5a..3331c86 100644 --- a/model/article.go +++ b/model/article.go @@ -1,10 +1,23 @@ package model type Article struct { - Authors []string `json:"authors,omitempty"` - Id string `json:"id"` - Status ArticleStatus `json:"status,omitempty"` - Title string `json:"title"` + Authors []string `json:"authors,omitempty"` + Category Category `json:"category,omitempty"` + Dimensions ArticleDimensions `json:"dimensions,omitempty"` + Id string `json:"id"` + Metadata *ArticleMetadata `json:"metadata,omitempty"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` +} + +type ArticleDimensions struct { + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +type ArticleMetadata struct { + ReadingTime float64 `json:"readingTime,omitempty"` + WordCount int `json:"wordCount,omitempty"` } type ArticleStatus string diff --git a/model/product.go b/model/product.go index 48c99e4..0ff364a 100644 --- a/model/product.go +++ b/model/product.go @@ -4,6 +4,7 @@ type Product struct { Active bool `json:"active,omitempty"` Classification ProductClassification `json:"classification,omitempty"` Description *string `json:"description,omitempty"` + Dimensions ProductDimensions `json:"dimensions,omitempty"` Id string `json:"id"` Name string `json:"name"` Price float64 `json:"price"` @@ -19,3 +20,16 @@ const ( ProductClassificationApplied ProductClassification = "applied" ProductClassificationExperimental ProductClassification = "experimental" ) + +type ProductDimensionsUnit string + +const ( + ProductDimensionsUnitCm ProductDimensionsUnit = "cm" + ProductDimensionsUnitInch ProductDimensionsUnit = "inch" +) + +type ProductDimensions struct { + Height float64 `json:"height,omitempty"` + Unit ProductDimensionsUnit `json:"unit,omitempty"` + Width float64 `json:"width,omitempty"` +} From 6ec41d4771305739afb04682efef1cebcac8d18e Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Sun, 28 Jun 2026 09:05:37 +0200 Subject: [PATCH 6/9] feat: add oneOf functionality --- docs/step-08-local-defs.md | 282 +++++++++++++++++++++++ docs/step-09-oneof-union.md | 395 ++++++++++++++++++++++++++++++++ internal/generator/generator.go | 156 +++++++++++-- model/article.go | 50 ++++ 4 files changed, 859 insertions(+), 24 deletions(-) create mode 100644 docs/step-08-local-defs.md create mode 100644 docs/step-09-oneof-union.md diff --git a/docs/step-08-local-defs.md b/docs/step-08-local-defs.md new file mode 100644 index 0000000..a8667fa --- /dev/null +++ b/docs/step-08-local-defs.md @@ -0,0 +1,282 @@ +# Step 08 — Local `$defs` + +## Goal + +Extend the generator to resolve **intra-schema `$defs` references** — local refs of the form +`{"$ref": "#/$defs/RichTextNode"}` — and to **emit a named Go type for every `$defs` entry** in a +schema. Each definition becomes a top-level struct named `` (e.g. +`ArticleRichText`), living in the same file as its parent schema. A local ref resolves at generation +time to that same name, so `RichText.nodes` (`array` of `#/$defs/RichTextNode`) becomes +`[]ArticleRichTextNode`. Because the resolved name is a plain Go type, the self-recursive +`RichTextNode.children` field resolves to `[]ArticleRichTextNode` with no extra work — Go supports +self-referential slice fields natively. Cross-schema refs (step 07) are unchanged. `oneOf` +(`Article.content`) and the `const` discriminator are still out of scope. All other behaviour from +step 07 is unchanged. + +## Inputs + +Same as step 07: `schemas/*.json`. Only `Article` carries a `$defs` block; `Product` and `Category` +have none. + +### Definitions in scope (`Article.$defs`) + +Each `$defs` entry is emitted as `Article`: + +| `$defs` key | Emitted type | Fields (Go) | +|----------------|-----------------------|-------------------------------------------------------------| +| `RichText` | `ArticleRichText` | `Format string`, `Html string`, `Nodes []ArticleRichTextNode` | +| `RichTextNode` | `ArticleRichTextNode` | `Children []ArticleRichTextNode`, `Text *string`, `Type string` | +| `PlainText` | `ArticlePlainText` | `Format string`, `Text string` | + +Notes: + +- `format` carries a `const` (`"richtext"` / `"plaintext"`) but also `"type":"string"`, so it is + emitted as a plain `string` field. The `const` discriminator refinement is **step 09**. +- `text` in `RichTextNode` is `["string","null"]` → `*string` via the existing nullable handling. +- `nodes` and `children` are arrays whose items are local refs; both resolve to + `[]ArticleRichTextNode`. `children` is self-recursive — handled natively by Go. + +The emitted `$defs` types are **not yet referenced** by `Article` (the `content` `oneOf` field stays +skipped until step 09). Unreferenced package-level types are valid Go and `go vet`-clean; step 09 +wires `Article.content` into a union over them. + +## `$ref` detection and resolution + +A local `$ref` is one whose value starts with `#`. Resolution takes the segment after the last `/`, +PascalCases it, and prefixes the **root schema title**: + +| `$ref` value | Branch | Resolves to | +|--------------------------|---------------|------------------------| +| `"Category.json"` | cross-schema | `Category` | +| `"#/$defs/RichText"` | local `$defs` | `ArticleRichText` | +| `"#/$defs/RichTextNode"` | local `$defs` | `ArticleRichTextNode` | +| `""` | none | `""` (field skipped) | + +The local branch needs the parent schema's title, so the **root title is threaded** through the +resolution helpers (see below). This is the one signature change relative to step 07, which kept +`resolveRef` a context-free string transform. + +## Why the root title is threaded + +Step 07's `resolveRef(ref string)` was a pure string transform because a cross-schema name is fully +determined by the ref value (`Category.json → Category`). A local def name, under the parent-prefixed +convention, is `rootTitle + defName` — information the ref value alone does not carry. The same root +must be used regardless of which struct the ref appears in (inside `ArticleRichText`, a ref to +`#/$defs/RichTextNode` must still produce `ArticleRichTextNode`, not `ArticleRichTextRichTextNode`). +So a `root string` parameter is threaded down `generateFields → setObjectType → setGoType → +setArrayType → resolveRef`. `objectType`, `setPrimitiveType`, and `setEnumType` are untouched. + +Local refs also appear as **array items** (`nodes`, `children`), which never pass through the +field-loop `$ref` case — they reach `setArrayType → setGoType(items)`. A `$ref` item has no `"type"`, +so step 07 dropped it. Step 08 makes `setGoType` ref-aware so array-item refs resolve too. + +## Implementation + +All changes are in `internal/generator/generator.go`. No changes to the `Schema` struct, +`NewGenerator`, `objectType`, `setPrimitiveType`, or `setEnumType`. + +### `resolveRef` — handle local refs and take the root title + +```go +// resolveRef maps a $ref to its Go type name. A cross-schema ref ("Category.json") strips the path +// and ".json" suffix → "Category". A local ref ("#/$defs/RichTextNode") takes the segment after the +// last "/", PascalCases it, and prefixes the root schema title → "ArticleRichTextNode". Empty → "". +// Cross-schema refs have no existence check: an unresolved reference simply won't compile. +func resolveRef(root, ref string) string { + if ref == "" { + return "" + } + if strings.HasPrefix(ref, "#") { + name := ref[strings.LastIndex(ref, "/")+1:] + return root + toPascalCase(name) + } + return strings.TrimSuffix(filepath.Base(ref), ".json") +} +``` + +### `setGoType` — resolve `$ref` array items, take the root title + +```go +func setGoType(root string, s *Schema) string { + if s.Ref != "" { + return resolveRef(root, s.Ref) + } + if s.Type == nil { + return "" + } + var single string + if err := json.Unmarshal(s.Type, &single); err == nil && single == ARRAY_TYPE { + return setArrayType(root, s) + } + return setPrimitiveType(s) +} +``` + +### `setArrayType` — thread the root title to item resolution + +```go +func setArrayType(root string, s *Schema) string { + if s.Items == nil { + return "" + } + elem := setGoType(root, s.Items) + if elem == "" { + return "" + } + return "[]" + elem +} +``` + +### `generateFields` / `setObjectType` — thread the root title + +```go +func generateFields(decls *bytes.Buffer, root, typeName string, s *Schema) string { + // ... unchanged setup ... + switch { + case len(property.Enum) > 0: + goType = setEnumType(decls, typeName, name, property.Enum) + case property.Ref != "": + goType = resolveRef(root, property.Ref) + default: + if isObj, nullable := objectType(property); isObj { + goType = setObjectType(decls, root, typeName+toPascalCase(name), property) + if nullable && goType != "" { + goType = "*" + goType + } + } else { + goType = setGoType(root, property) + } + } + // ... unchanged tag/emit ... +} + +func setObjectType(decls *bytes.Buffer, root, typeName string, s *Schema) string { + body := generateFields(decls, root, typeName, s) + if body == "" { + return "" + } + fmt.Fprintf(decls, "type %s struct {\n%s}\n\n", typeName, body) + return typeName +} +``` + +### `Generate` — emit one struct per `$defs` entry + +After building the main struct body (which fills `decls` with field-driven enum/object decls), emit +the `$defs` types into the same `decls` buffer in sorted order: + +```go +body := generateFields(&decls, mainSchema.Title, mainSchema.Title, mainSchema) +if body == "" { + continue +} + +defNames := make([]string, 0, len(mainSchema.Defs)) +for name := range mainSchema.Defs { + defNames = append(defNames, name) +} +sort.Strings(defNames) +for _, name := range defNames { + setObjectType(&decls, mainSchema.Title, mainSchema.Title+toPascalCase(name), mainSchema.Defs[name]) +} +``` + +`$defs` structs are appended after the main struct's field decls, so output is deterministic across +runs (sorted def names, sorted fields). `RichText` referencing `RichTextNode` (declared later) is +fine — same package, no forward declaration needed. + +## Output + +### model/article.go + +```go +package model + +type Article struct { + Authors []string `json:"authors,omitempty"` + Category Category `json:"category,omitempty"` + Dimensions ArticleDimensions `json:"dimensions,omitempty"` + Id string `json:"id"` + Metadata *ArticleMetadata `json:"metadata,omitempty"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` +} + +type ArticleDimensions struct { + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +type ArticleMetadata struct { + ReadingTime float64 `json:"readingTime,omitempty"` + WordCount int `json:"wordCount,omitempty"` +} + +type ArticleStatus string + +const ( + ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusPublished ArticleStatus = "published" + ArticleStatusArchived ArticleStatus = "archived" +) + +type ArticlePlainText struct { + Format string `json:"format,omitempty"` + Text string `json:"text,omitempty"` +} + +type ArticleRichText struct { + Format string `json:"format,omitempty"` + Html string `json:"html,omitempty"` + Nodes []ArticleRichTextNode `json:"nodes,omitempty"` +} + +type ArticleRichTextNode struct { + Children []ArticleRichTextNode `json:"children,omitempty"` + Text *string `json:"text,omitempty"` + Type string `json:"type,omitempty"` +} +``` + +The `Article` struct and the `ArticleDimensions`/`ArticleMetadata`/`ArticleStatus` declarations are +unchanged from step 07. The three `$defs` types are the only additions; `content` (`oneOf`, no `$ref` +at the property level) still resolves to `""` and is skipped. + +### model/category.go + +Unchanged from step 05. + +### model/product.go + +Unchanged — `Product` has no `$defs`. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/article.go` declares `ArticlePlainText`, `ArticleRichText`, and `ArticleRichTextNode`, + with `Nodes []ArticleRichTextNode` and the self-recursive `Children []ArticleRichTextNode` +- [ ] `Article` struct, `model/category.go`, and `model/product.go` are unchanged +- [ ] `go test ./...` passes all 8 harness subtests — `7.json` (RichText `content`) and `8.json` + (PlainText `content`) still unmarshal into `Article{}`; the extra `content` key is ignored + because `Article` has no `Content` field yet (added in step 09) +- [ ] `go vet ./...` is clean (the new `$defs` types are package-level and may be unreferenced) +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 07. Local refs now resolve to `root + defName` instead of `""`; a +local ref whose `$defs` entry is missing yields a reference to an undefined type and won't compile — +the same accepted trade-off as cross-schema refs, not a generator error. + +## Out of scope + +- `Article.content` (`oneOf` discriminated union) — step 09 +- `const` discriminator fields (`format: "richtext"` / `"plaintext"`) — step 09 +- Recursive-type verification / round-trip payloads for `children` — step 10 (the field is already + emitted here; step 10 confirms it round-trips) diff --git a/docs/step-09-oneof-union.md b/docs/step-09-oneof-union.md new file mode 100644 index 0000000..45cbcf4 --- /dev/null +++ b/docs/step-09-oneof-union.md @@ -0,0 +1,395 @@ +# Step 09 — `oneOf` Discriminated Union + +## Goal + +Extend the generator to resolve the **`oneOf` discriminated union** at `Article.content` — a `oneOf` +over `#/$defs/RichText` and `#/$defs/PlainText` — into idiomatic Go. Each union member is a local +`$ref` to a `$defs` type already emitted in step 08 (`ArticleRichText`, `ArticlePlainText`). For the +union property, step 09 emits two things into the parent schema's file: + +1. a **union struct** `ArticleContent` with one nullable pointer field per variant + (`RichText *ArticleRichText`, `PlainText *ArticlePlainText`); +2. a custom **`UnmarshalJSON`** on `*ArticleContent` that inspects the `format` discriminator and + populates the matching pointer field. + +`Article` gains a `Content *ArticleContent` field (pointer, so `omitempty` works and nil means +absent). The `format` field inside each variant stays a plain `string` — the `const` is read at +**generation time** to build the discriminator switch, not to retype the field. All other behaviour +from step 08 is unchanged. Recursive-type round-trip verification stays out of scope (step 10). + +## Trade-off vs interface approach + +A struct-of-pointers allows (in theory) both fields to be non-nil simultaneously, which violates +`oneOf` semantics. This is acceptable because `UnmarshalJSON` enforces mutual exclusivity at decode +time, and the harness only tests unmarshaling. The benefit is much simpler calling code: nil-check +the field you need rather than type-asserting through an interface. + +## Inputs + +Same as step 08: `schemas/*.json`. Only `Article` carries a `oneOf` (`content`) and the `const` +discriminators (`RichText.format`, `PlainText.format`). `Product` and `Category` have neither. + +### Union members in scope (`Article.content.oneOf`) + +```json +"content": { + "oneOf": [ + { "$ref": "#/$defs/RichText" }, + { "$ref": "#/$defs/PlainText" } + ] +} +``` + +| member `$ref` | variant Go type | struct field | discriminator | +|---------------------|--------------------|---------------|----------------------| +| `#/$defs/RichText` | `ArticleRichText` | `RichText` | `format` = `richtext` | +| `#/$defs/PlainText` | `ArticlePlainText` | `PlainText` | `format` = `plaintext` | + +Notes: + +- `content` is **not** in the schema's top-level `required` (`["id","title"]`), so the field is tagged + `,omitempty`. Using `*ArticleContent` ensures a missing `content` key leaves the field `nil`. +- `RichText.format` is `{"type":"string","const":"richtext"}` and `PlainText.format` is + `{"type":"string","const":"plaintext"}`. The `"const"` drives the switch; the field itself remains + `Format string` in each variant struct (unchanged from step 08). +- `RichTextNode` has no `format` and is not a `oneOf` member — untouched. + +## `oneOf` detection and discriminator resolution + +A property is a union when `len(property.OneOf) > 0`. For each member: + +1. `resolveReferences(root.Title, member.Ref)` returns **both** the variant Go type and the bare def + key in one call → `("ArticleRichText", "RichText")` / `("ArticlePlainText", "PlainText")`. The bare + key is the raw segment after the last `/`, used to index `root.Defs` and (PascalCased) as the field + name — no separate `strings.LastIndex` extraction in `setUnionType`. +2. The struct field name is `toPascalCase(defKey)` → `RichText` / `PlainText`. +3. The discriminator **key** and **value** come from that def: the (sorted-first) property whose + `Const` is a non-empty JSON string → key `format`, value `richtext` / `plaintext`. + +## Why the root `*Schema` is threaded + +Step 08 threaded `root string` (the schema **title**) so local refs could be prefixed +(`RichTextNode → ArticleRichTextNode`). Step 09 needs the title **and** the `$defs` map at the +property loop: the discriminator `const` lives in the *target def* (`RichText.format.const`), which is +only reachable through `root.Defs`. The ref value alone does not carry it. + +The minimal change is to thread the **root `*Schema`** (instead of `root string`) through the two +**recursive** functions only — `generateFields` and `setObjectType`. Everywhere a name prefix was +needed, callers now pass `root.Title`; the new union path reads `root.Defs`. The leaf helpers +(`setGoType`, `setArrayType`, `setPrimitiveType`, `setEnumType`) keep their `root string` signatures +unchanged, and `objectType` is untouched. `resolveReferences` keeps its `root string` parameter but +now returns `(goType, name string)` (see below). These are the signature changes relative to step 08. + +## Implementation + +All changes are in `internal/generator/generator.go`. No changes to the `Schema` struct (it already +declares `OneOf []*Schema` and `Const json.RawMessage`), `NewGenerator`, `objectType`, +`setPrimitiveType`, `setEnumType`, `setGoType`, or `setArrayType`. `resolveReferences` is expanded to +return the bare ref name alongside the Go type (see below). + +### `generateFields` / `setObjectType` — take `root *Schema`, add the `oneOf` case + +```go +func generateFields(references *bytes.Buffer, root *Schema, typeName string, s *Schema) string { + // ... unchanged setup ... + switch { + case len(property.Enum) > 0: + goType = setEnumType(references, typeName, name, property.Enum) + case len(property.OneOf) > 0: + goType = setUnionType(references, root, typeName+toPascalCase(name), property.OneOf) + if goType != "" { + goType = "*" + goType + } + case property.Ref != "": + goType, _ = resolveReferences(root.Title, property.Ref) + default: + if isObj, nullable := objectType(property); isObj { + goType = setObjectType(references, root, typeName+toPascalCase(name), property) + if nullable && goType != "" { + goType = "*" + goType + } + } else { + goType = setGoType(root.Title, property) + } + } + // ... unchanged tag/emit ... +} + +func setObjectType(decls *bytes.Buffer, root *Schema, typeName string, s *Schema) string { + body := generateFields(decls, root, typeName, s) + if body == "" { + return "" + } + fmt.Fprintf(decls, "type %s struct {\n%s}\n\n", typeName, body) + return typeName +} +``` + +### `resolveReferences` — also return the bare ref name + +`resolveReferences` already computed the ref's last path segment internally and discarded it, returning +only the root-prefixed Go type. It now returns that bare name too, so `setUnionType` can index +`root.Defs` and build the field name without re-extracting the segment. The bare name is the **raw** +def key (no `toPascalCase`), so it indexes `root.Defs` directly; PascalCasing is left to callers. The +two existing single-value callers (`generateFields`' `$ref` case, `setGoType`) discard the new return +with `_`. + +```go +// resolveReferences maps a $ref to its Go type name and the bare ref name. A local ref +// ("#/$defs/RichTextNode") PascalCases the segment after the last "/" and prefixes the root schema +// title → ("ArticleRichTextNode", "RichTextNode"). A cross-schema ref ("Category.json") strips the +// path and ".json" suffix → ("Category", "Category"). Empty → ("", ""). +func resolveReferences(root, ref string) (goType, name string) { + if ref == "" { + return "", "" + } + name = strings.TrimSuffix(filepath.Base(ref), ".json") + if strings.HasPrefix(ref, "#") { + return root + toPascalCase(name), name + } + return name, name +} +``` + +### `discriminator` — find the `const`-bearing property + +```go +// discriminator returns the JSON key and string value of the first (sorted) property carrying a +// non-empty string const — the field that selects a oneOf variant. ("", "") if none. +func discriminator(s *Schema) (key, value string) { + names := make([]string, 0, len(s.Properties)) + for n := range s.Properties { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + p := s.Properties[n] + if len(p.Const) == 0 { + continue + } + var v string + if err := json.Unmarshal(p.Const, &v); err == nil { + return n, v + } + } + return "", "" +} +``` + +### `setUnionType` — emit union struct + `UnmarshalJSON` + +Variants are kept in `oneOf` array order, so output is deterministic across runs. + +```go +func setUnionType(decls *bytes.Buffer, root *Schema, typeName string, members []*Schema) string { + type variant struct{ goType, fieldName, disc string } + var variants []variant + discKey := "" + for _, m := range members { + if m.Ref == "" { + continue + } + goType, defKey := resolveReferences(root.Title, m.Ref) + def := root.Defs[defKey] + if def == nil { + continue + } + k, v := discriminator(def) + if k == "" { + continue + } + discKey = k + variants = append(variants, variant{ + goType: goType, + fieldName: toPascalCase(defKey), + disc: v, + }) + } + if len(variants) == 0 { + return "" + } + + fmt.Fprintf(decls, "type %s struct {\n", typeName) + for _, v := range variants { + fmt.Fprintf(decls, "\t%s *%s\n", v.fieldName, v.goType) + } + fmt.Fprintf(decls, "}\n\n") + + field := toPascalCase(discKey) + fmt.Fprintf(decls, "func (c *%s) UnmarshalJSON(data []byte) error {\n", typeName) + fmt.Fprintf(decls, "\tif string(data) == \"null\" {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(decls, "\tvar disc struct {\n\t\t%s string `json:%q`\n\t}\n", field, discKey) + fmt.Fprintf(decls, "\tif err := json.Unmarshal(data, &disc); err != nil {\n\t\treturn err\n\t}\n") + fmt.Fprintf(decls, "\tswitch disc.%s {\n", field) + for _, v := range variants { + fmt.Fprintf(decls, "\tcase %q:\n\t\tc.%s = &%s{}\n\t\treturn json.Unmarshal(data, c.%s)\n", + v.disc, v.fieldName, v.goType, v.fieldName) + } + fmt.Fprintf(decls, "\tdefault:\n\t\treturn fmt.Errorf(\"unknown %s %%q\", disc.%s)\n\t}\n}\n\n", discKey, field) + return typeName +} +``` + +### `Generate` — thread `root *Schema`, emit a conditional import block + +The generated file needs `encoding/json` and `fmt` only when a union is emitted. +`format.Source` (gofmt) does **not** add imports, so they must be emitted explicitly; the +`hasOneOf` gate keeps `product.go` / `category.go` import-free. + +```go +func hasOneOf(s *Schema) bool { + for _, p := range s.Properties { + if len(p.OneOf) > 0 { + return true + } + } + return false +} + +// inside the per-schema loop: +body := generateFields(&references, mainSchema, mainSchema.Title, mainSchema) +if body == "" { + continue +} + +// ...sort and emit $defs... + +header := "package model\n\n" +if hasOneOf(mainSchema) { + header += "import (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n" +} +fmt.Fprintf(&buf, "%stype %s struct {\n%s}\n", header, mainSchema.Title, body) +if references.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", references.String()) +} +``` + +## Output + +### model/article.go + +```go +package model + +import ( + "encoding/json" + "fmt" +) + +type Article struct { + Authors []string `json:"authors,omitempty"` + Category Category `json:"category,omitempty"` + Content *ArticleContent `json:"content,omitempty"` + Dimensions ArticleDimensions `json:"dimensions,omitempty"` + Id string `json:"id"` + Metadata *ArticleMetadata `json:"metadata,omitempty"` + Status ArticleStatus `json:"status,omitempty"` + Title string `json:"title"` +} + +type ArticleContent struct { + RichText *ArticleRichText + PlainText *ArticlePlainText +} + +func (c *ArticleContent) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var disc struct { + Format string `json:"format"` + } + if err := json.Unmarshal(data, &disc); err != nil { + return err + } + switch disc.Format { + case "richtext": + c.RichText = &ArticleRichText{} + return json.Unmarshal(data, c.RichText) + case "plaintext": + c.PlainText = &ArticlePlainText{} + return json.Unmarshal(data, c.PlainText) + default: + return fmt.Errorf("unknown format %q", disc.Format) + } +} + +type ArticleDimensions struct { + Height int `json:"height,omitempty"` + Width int `json:"width,omitempty"` +} + +type ArticleMetadata struct { + ReadingTime float64 `json:"readingTime,omitempty"` + WordCount int `json:"wordCount,omitempty"` +} + +type ArticleStatus string + +const ( + ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusPublished ArticleStatus = "published" + ArticleStatusArchived ArticleStatus = "archived" +) + +type ArticlePlainText struct { + Format string `json:"format,omitempty"` + Text string `json:"text,omitempty"` +} + +type ArticleRichText struct { + Format string `json:"format,omitempty"` + Html string `json:"html,omitempty"` + Nodes []ArticleRichTextNode `json:"nodes,omitempty"` +} + +type ArticleRichTextNode struct { + Children []ArticleRichTextNode `json:"children,omitempty"` + Text *string `json:"text,omitempty"` + Type string `json:"type,omitempty"` +} +``` + +The additions versus step 08 are: the `import` block, the `Content` field (now `*ArticleContent`), +and the union block (`ArticleContent` struct + `UnmarshalJSON`). The `$defs` declarations are unchanged. + +### model/category.go + +Unchanged from step 05 — no `oneOf`, no import block. + +### model/product.go + +Unchanged — `Product` has no `oneOf`. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `model/article.go` has `import ("encoding/json"; "fmt")`, a `Content *ArticleContent` field, + and the union block (`ArticleContent` struct with pointer fields + `UnmarshalJSON`) +- [ ] `7.json` (RichText `content`) unmarshals with `Content.RichText` non-nil, `Content.PlainText` nil +- [ ] `8.json` (PlainText `content`) unmarshals with `Content.PlainText` non-nil, `Content.RichText` nil +- [ ] `model/category.go`, `model/product.go`, and the step-08 `$defs` types are unchanged +- [ ] `go test ./...` passes all 8 harness subtests +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits all contracts from step 08. `ArticleContent.UnmarshalJSON` returns the underlying error on +malformed JSON, no-ops on a JSON `null` (the zero `*ArticleContent` is left nil), and returns +`fmt.Errorf("unknown format %q", …)` when the discriminator matches no variant. A `oneOf` member +whose def or discriminator cannot be resolved is skipped; if no variant resolves, the union emits +nothing and the field is dropped. + +## Out of scope + +- `MarshalJSON` on `ArticleContent` (without it, re-marshaling wraps as `{"RichText":…}` or + `{"PlainText":…}` with the nil field omitted only if the struct field had `omitempty` — a natural + "with more time" enhancement). +- Recursive-type round-trip verification for `RichTextNode.children` — step 10. diff --git a/internal/generator/generator.go b/internal/generator/generator.go index c4de88c..8adf448 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -74,22 +74,45 @@ func NewGenerator(dir string) (*Generator, error) { return g, nil } +// hasOneOf reports whether any top-level property of s uses oneOf. +func hasOneOf(s *Schema) bool { + for _, p := range s.Properties { + if len(p.OneOf) > 0 { + return true + } + } + return false +} + // Generate returns a map of filename → gofmt-formatted Go source, one entry per schema. func (g *Generator) Generate() (map[string][]byte, error) { schemasMap := make(map[string][]byte, len(g.schemas)) for _, mainSchema := range g.schemas { var buf bytes.Buffer - var decls bytes.Buffer + var references bytes.Buffer - body := generateFields(&decls, mainSchema.Title, mainSchema) + body := generateFields(&references, mainSchema, mainSchema.Title, mainSchema) if body == "" { continue } - fmt.Fprintf(&buf, "package model\n\ntype %s struct {\n%s}\n", mainSchema.Title, body) - if decls.Len() > 0 { - fmt.Fprintf(&buf, "\n%s", decls.String()) + defNames := make([]string, 0, len(mainSchema.Defs)) + for name := range mainSchema.Defs { + defNames = append(defNames, name) + } + sort.Strings(defNames) + for _, name := range defNames { + setObjectType(&references, mainSchema, mainSchema.Title+toPascalCase(name), mainSchema.Defs[name]) + } + + header := "package model\n\n" + if hasOneOf(mainSchema) { + header += "import (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n" + } + fmt.Fprintf(&buf, "%stype %s struct {\n%s}\n", header, mainSchema.Title, body) + if references.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", references.String()) } src, err := format.Source(buf.Bytes()) @@ -134,7 +157,7 @@ func objectType(s *Schema) (isObject, nullable bool) { // generateFields builds the struct body for a struct named typeName from s.Properties, // emitting any nested enum/object type declarations into decls. Properties are sorted // for deterministic output. -func generateFields(decls *bytes.Buffer, typeName string, s *Schema) string { +func generateFields(refBuffer *bytes.Buffer, root *Schema, typeName string, s *Schema) string { requiredFields := make(map[string]bool, len(s.Required)) for _, r := range s.Required { requiredFields[r] = true @@ -153,17 +176,22 @@ func generateFields(decls *bytes.Buffer, typeName string, s *Schema) string { var goType string switch { case len(property.Enum) > 0: - goType = setEnumType(decls, typeName, name, property.Enum) + goType = setEnumType(refBuffer, typeName, name, property.Enum) + case len(property.OneOf) > 0: + goType = setUnionType(refBuffer, root, typeName+toPascalCase(name), property.OneOf) + if goType != "" { + goType = "*" + goType + } case property.Ref != "": - goType = resolveRef(property.Ref) + goType, _ = resolveReferences(root.Title, property.Ref) default: if isObj, nullable := objectType(property); isObj { - goType = setObjectType(decls, typeName+toPascalCase(name), property) + goType = setObjectType(refBuffer, root, typeName+toPascalCase(name), property) if nullable && goType != "" { goType = "*" + goType } } else { - goType = setGoType(property) + goType = setGoType(root.Title, property) } } if goType == "" { @@ -183,8 +211,8 @@ func generateFields(decls *bytes.Buffer, typeName string, s *Schema) string { // returns the type name. Nested enums/objects are emitted (recursively) before the // struct itself. An object with no resolvable fields emits nothing and returns "" so the // caller skips the field. -func setObjectType(decls *bytes.Buffer, typeName string, s *Schema) string { - body := generateFields(decls, typeName, s) +func setObjectType(decls *bytes.Buffer, root *Schema, typeName string, s *Schema) string { + body := generateFields(decls, root, typeName, s) if body == "" { return "" } @@ -192,27 +220,102 @@ func setObjectType(decls *bytes.Buffer, typeName string, s *Schema) string { return typeName } +// discriminator returns the JSON key and string value of the first (sorted) property +// carrying a non-empty string const — the field that selects a oneOf variant. +func discriminator(s *Schema) (key, value string) { + names := make([]string, 0, len(s.Properties)) + for n := range s.Properties { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + p := s.Properties[n] + if len(p.Const) == 0 { + continue + } + var v string + if err := json.Unmarshal(p.Const, &v); err == nil { + return n, v + } + } + return "", "" +} + +// setUnionType emits a struct with one pointer field per variant and an UnmarshalJSON +// for a oneOf union into decls. Returns the struct type name, or "" if no variant resolves. +func setUnionType(decls *bytes.Buffer, root *Schema, typeName string, members []*Schema) string { + type variant struct{ goType, fieldName, disc string } + var variants []variant + discKey := "" + for _, m := range members { + if m.Ref == "" { + continue + } + goType, defKey := resolveReferences(root.Title, m.Ref) + def := root.Defs[defKey] + if def == nil { + continue + } + k, v := discriminator(def) + if k == "" { + continue + } + discKey = k + variants = append(variants, variant{ + goType: goType, + fieldName: toPascalCase(defKey), + disc: v, + }) + } + if len(variants) == 0 { + return "" + } + + fmt.Fprintf(decls, "type %s struct {\n", typeName) + for _, v := range variants { + fmt.Fprintf(decls, "\t%s *%s\n", v.fieldName, v.goType) + } + fmt.Fprintf(decls, "}\n\n") + + field := toPascalCase(discKey) + fmt.Fprintf(decls, "func (c *%s) UnmarshalJSON(data []byte) error {\n", typeName) + fmt.Fprintf(decls, "\tif string(data) == \"null\" {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(decls, "\tvar disc struct {\n\t\t%s string `json:%q`\n\t}\n", field, discKey) + fmt.Fprintf(decls, "\tif err := json.Unmarshal(data, &disc); err != nil {\n\t\treturn err\n\t}\n") + fmt.Fprintf(decls, "\tswitch disc.%s {\n", field) + for _, v := range variants { + fmt.Fprintf(decls, "\tcase %q:\n\t\tc.%s = &%s{}\n\t\treturn json.Unmarshal(data, c.%s)\n", + v.disc, v.fieldName, v.goType, v.fieldName) + } + fmt.Fprintf(decls, "\tdefault:\n\t\treturn fmt.Errorf(\"unknown %s %%q\", disc.%s)\n\t}\n}\n\n", discKey, field) + return typeName +} + // setGoType returns the Go type string for a schema node, or "" if the type cannot // be mapped to a primitive (array, object, multi-type beyond nullable, etc.). // Handles both single-string types ("string") and nullable pairs (["string","null"]). -func setGoType(s *Schema) string { +func setGoType(root string, s *Schema) string { + if s.Ref != "" { + gt, _ := resolveReferences(root, s.Ref) + return gt + } if s.Type == nil { return "" } var single string if err := json.Unmarshal(s.Type, &single); err == nil && single == ARRAY_TYPE { - return setArrayType(s) + return setArrayType(root, s) } return setPrimitiveType(s) } // setArrayType returns "[]T" for array schemas whose items resolve to a known type. // Returns "" for unresolvable items (object, $ref, missing, etc.). -func setArrayType(s *Schema) string { +func setArrayType(root string, s *Schema) string { if s.Items == nil { return "" } - elem := setGoType(s.Items) + elem := setGoType(root, s.Items) if elem == "" { return "" } @@ -245,15 +348,20 @@ func setEnumType(decls *bytes.Buffer, schemaName, property string, values []json return typeName } -// resolveRef maps a cross-schema $ref like "Category.json" to its Go type name by stripping -// the path and ".json" suffix. Local refs ("#/...") are out of scope (steps 08+) and return "". -// There is no existence check: if the referenced schema is never generated, the emitted -// reference won't compile and must be fixed manually. -func resolveRef(ref string) string { - if ref == "" || strings.HasPrefix(ref, "#") { - return "" +// resolveReferences maps a $ref to its Go type name and the bare ref name. A local ref +// ("#/$defs/RichTextNode") PascalCases the segment after the last "/" and prefixes the root schema +// title → ("ArticleRichTextNode", "RichTextNode"). A cross-schema ref ("Category.json") strips the +// path and ".json" suffix → ("Category", "Category"). The bare name is the raw def key, so callers +// can index root.Defs with it; PascalCasing is left to callers. Empty → ("", ""). +func resolveReferences(root, ref string) (goType, name string) { + if ref == "" { + return "", "" + } + name = strings.TrimSuffix(filepath.Base(ref), ".json") + if strings.HasPrefix(ref, "#") { + return root + toPascalCase(name), name } - return strings.TrimSuffix(filepath.Base(ref), ".json") + return name, name } // setPrimitiveType returns the Go type string for a schema node, or "" if the type cannot diff --git a/model/article.go b/model/article.go index 3331c86..d8be736 100644 --- a/model/article.go +++ b/model/article.go @@ -1,8 +1,14 @@ package model +import ( + "encoding/json" + "fmt" +) + type Article struct { Authors []string `json:"authors,omitempty"` Category Category `json:"category,omitempty"` + Content *ArticleContent `json:"content,omitempty"` Dimensions ArticleDimensions `json:"dimensions,omitempty"` Id string `json:"id"` Metadata *ArticleMetadata `json:"metadata,omitempty"` @@ -10,6 +16,33 @@ type Article struct { Title string `json:"title"` } +type ArticleContent struct { + RichText *ArticleRichText + PlainText *ArticlePlainText +} + +func (c *ArticleContent) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var disc struct { + Format string `json:"format"` + } + if err := json.Unmarshal(data, &disc); err != nil { + return err + } + switch disc.Format { + case "richtext": + c.RichText = &ArticleRichText{} + return json.Unmarshal(data, c.RichText) + case "plaintext": + c.PlainText = &ArticlePlainText{} + return json.Unmarshal(data, c.PlainText) + default: + return fmt.Errorf("unknown format %q", disc.Format) + } +} + type ArticleDimensions struct { Height int `json:"height,omitempty"` Width int `json:"width,omitempty"` @@ -27,3 +60,20 @@ const ( ArticleStatusPublished ArticleStatus = "published" ArticleStatusArchived ArticleStatus = "archived" ) + +type ArticlePlainText struct { + Format string `json:"format,omitempty"` + Text string `json:"text,omitempty"` +} + +type ArticleRichText struct { + Format string `json:"format,omitempty"` + Html string `json:"html,omitempty"` + Nodes []ArticleRichTextNode `json:"nodes,omitempty"` +} + +type ArticleRichTextNode struct { + Children []ArticleRichTextNode `json:"children,omitempty"` + Text *string `json:"text,omitempty"` + Type string `json:"type,omitempty"` +} From 076ccf98a111342f4561b75d3d1122a8bea4fb63 Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Sun, 28 Jun 2026 10:01:11 +0200 Subject: [PATCH 7/9] feat: refactor and some clean up --- docs/step-10-refactor-phase.md | 133 +++++++++++++++++++++++ internal/generator/generator.go | 184 ++++++++++++-------------------- internal/generator/helpers.go | 62 +++++++++++ model/article.go | 8 +- 4 files changed, 267 insertions(+), 120 deletions(-) create mode 100644 docs/step-10-refactor-phase.md diff --git a/docs/step-10-refactor-phase.md b/docs/step-10-refactor-phase.md new file mode 100644 index 0000000..2208381 --- /dev/null +++ b/docs/step-10-refactor-phase.md @@ -0,0 +1,133 @@ +# Step 10 — Refactor Phase + +This step is a **refactor phase**: no new generated output, no behaviour change. Each refactor +below tightens the generator's internals while keeping `model/*.go` **byte-identical** to step 09. +Refactors are listed in the order they were applied. + +## Goal + +Improve the structure of `internal/generator/generator.go` without changing what it emits. Every +refactor in this step must satisfy the same invariant: + +- `go run ./cmd` regenerates `model/article.go`, `model/category.go`, `model/product.go` with **no + diff** versus before the refactor. +- `go test ./...` and `go vet ./...` stay green. + +--- + +## Refactor 1 — Replace `discriminator` with a `constValue` leaf helper + +### Motivation + +Reading a property's `const` lived entirely inside `discriminator`, the function that resolves a +`oneOf` variant's discriminator. `discriminator` did two unrelated jobs at once: + +1. **scan** a whole def's properties (sorted) to find *which* one carries a `const`, and +2. **read** that single property's `const` JSON string value. + +Job (2) — "does this one property have a string const?" — is a leaf operation in the same family as +`setGoType` / `setPrimitiveType` (single schema node in, value out). Job (1) — the property scan — +needs the property *name* (`format`) for the switch key, so it belongs at the def level, in the only +caller that needs it (`setUnionType`). Splitting them removes the standalone `discriminator` function +and puts the `const` read where the other leaf type helpers live. + +### Scope + +All changes are in `internal/generator/generator.go`. No changes to the `Schema` struct, +`NewGenerator`, `Generate`, `generateFields`, `setObjectType`, `resolveReferences`, `setGoType`, +`setArrayType`, `setPrimitiveType`, `setEnumType`, or `objectType`. `const` is now read in exactly one +place (`constValue`). + +### `constValue` — new leaf helper (next to `setPrimitiveType`) + +A single property in, the string `const` out. Returns `("", false)` when the node has no `const` or +its `const` is not a JSON string — same skip semantics the old code relied on. + +```go +// constValue returns the schema node's const as a string and true, or ("", false) +// if the node has no const or its const is not a JSON string. +func constValue(s *Schema) (string, bool) { + if len(s.Const) == 0 { + return "", false + } + var v string + if err := json.Unmarshal(s.Const, &v); err != nil { + return "", false + } + return v, true +} +``` + +### `discriminator` — deleted + +The whole function (the sorted property scan + `const` read) is removed; its scan moves into +`setUnionType`, its `const` read becomes `constValue`. + +### `setUnionType` — inline the discriminator scan + +The single `k, v := discriminator(def)` call is replaced by the sorted scan, now delegating the +per-property `const` read to `constValue`. Behaviour is preserved exactly: properties are sorted, the +first one with a string `const` wins, and a variant whose def exposes no discriminator is skipped. +The rest of `setUnionType` (struct-of-pointers + `UnmarshalJSON` emission) is untouched. + +```go + def := root.Defs[defKey] + if def == nil { + continue + } + names := make([]string, 0, len(def.Properties)) + for n := range def.Properties { + names = append(names, n) + } + sort.Strings(names) + k, v := "", "" + for _, n := range names { + if cv, ok := constValue(def.Properties[n]); ok { + k, v = n, cv + break + } + } + if k == "" { + continue + } + discKey = k + variants = append(variants, variant{ + goType: goType, + fieldName: toPascalCase(defKey), + disc: v, + }) +``` + +`sort` is already imported and used elsewhere, so no import changes. + +### Why not fold the `const` check into `setGoType` + +`setGoType` maps **one property's** schema to a Go type string; it cannot report the property's +*name*, which the switch key needs. A `const` field also still renders as its primitive type +(`Format string`) — the type mapping needs no change. Making `setGoType` also return the `const` +would churn its signature for every caller (`generateFields`, the recursive `setArrayType`, itself) +to carry a signal only the union path uses. Keeping `constValue` as its own leaf is the smaller, +clearer change. + +--- + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and prints three `wrote …` lines +- [ ] `git diff model/` is empty — `model/article.go`, `model/category.go`, `model/product.go` are + byte-identical to step 09 +- [ ] `discriminator` no longer exists; `constValue` is the only place `Const` is read +- [ ] `go test ./...` passes all 8 harness subtests +- [ ] `go vet ./...` is clean +- [ ] Repeated runs produce byte-identical output + +## Out of scope + +- Any change to generated output or the `oneOf` / discriminator semantics (step 09 covers those). +- `MarshalJSON` on `ArticleContent` and recursive-type round-trip verification — still future work. diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 8adf448..3888a4e 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -74,25 +74,15 @@ func NewGenerator(dir string) (*Generator, error) { return g, nil } -// hasOneOf reports whether any top-level property of s uses oneOf. -func hasOneOf(s *Schema) bool { - for _, p := range s.Properties { - if len(p.OneOf) > 0 { - return true - } - } - return false -} - // Generate returns a map of filename → gofmt-formatted Go source, one entry per schema. func (g *Generator) Generate() (map[string][]byte, error) { schemasMap := make(map[string][]byte, len(g.schemas)) for _, mainSchema := range g.schemas { var buf bytes.Buffer - var references bytes.Buffer + var refBuf bytes.Buffer - body := generateFields(&references, mainSchema, mainSchema.Title, mainSchema) + body := generateFields(&refBuf, mainSchema, mainSchema.Title, mainSchema) if body == "" { continue } @@ -103,16 +93,17 @@ func (g *Generator) Generate() (map[string][]byte, error) { } sort.Strings(defNames) for _, name := range defNames { - setObjectType(&references, mainSchema, mainSchema.Title+toPascalCase(name), mainSchema.Defs[name]) + setObjectType(&refBuf, mainSchema, mainSchema.Title+toPascalCase(name), mainSchema.Defs[name]) } - header := "package model\n\n" + fileHeader := "package model\n\n" if hasOneOf(mainSchema) { - header += "import (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n" + fileHeader += "import (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n" } - fmt.Fprintf(&buf, "%stype %s struct {\n%s}\n", header, mainSchema.Title, body) - if references.Len() > 0 { - fmt.Fprintf(&buf, "\n%s", references.String()) + + fmt.Fprintf(&buf, "%stype %s struct {\n%s}\n", fileHeader, mainSchema.Title, body) + if refBuf.Len() > 0 { + fmt.Fprintf(&buf, "\n%s", refBuf.String()) } src, err := format.Source(buf.Bytes()) @@ -127,33 +118,6 @@ func (g *Generator) Generate() (map[string][]byte, error) { return schemasMap, nil } -// objectType reports whether s is an inline object and whether it is nullable -// (type ["object","null"]). Single "object" → (true,false); ["object","null"] → -// (true,true); anything else → (false,false). $ref and oneOf nodes carry no "type" -// and so are never treated as inline objects (handled in later steps). -func objectType(s *Schema) (isObject, nullable bool) { - var single string - if err := json.Unmarshal(s.Type, &single); err == nil { - return single == "object", false - } - var multi []string - if err := json.Unmarshal(s.Type, &multi); err == nil && len(multi) == 2 { - var hasObject, hasNull bool - for _, t := range multi { - switch t { - case "object": - hasObject = true - case "null": - hasNull = true - } - } - // it looks redundant to return the same value twice, but the first return is for isObject and the second is for nullable - // but we are evaluating this scenario: ["object", "null"] which means it is an object and it is nullable. - return hasObject && hasNull, hasObject && hasNull - } - return false, false -} - // generateFields builds the struct body for a struct named typeName from s.Properties, // emitting any nested enum/object type declarations into decls. Properties are sorted // for deterministic output. @@ -173,28 +137,28 @@ func generateFields(refBuffer *bytes.Buffer, root *Schema, typeName string, s *S for _, name := range propertiesNames { property := s.Properties[name] - var goType string + var goTypeName string switch { case len(property.Enum) > 0: - goType = setEnumType(refBuffer, typeName, name, property.Enum) + goTypeName = setEnumType(refBuffer, typeName, name, property.Enum) case len(property.OneOf) > 0: - goType = setUnionType(refBuffer, root, typeName+toPascalCase(name), property.OneOf) - if goType != "" { - goType = "*" + goType + goTypeName = setUnionType(refBuffer, root, typeName+toPascalCase(name), property.OneOf) + if goTypeName != "" { + goTypeName = "*" + goTypeName } case property.Ref != "": - goType, _ = resolveReferences(root.Title, property.Ref) + goTypeName = resolveReferences(root.Title, property.Ref) default: if isObj, nullable := objectType(property); isObj { - goType = setObjectType(refBuffer, root, typeName+toPascalCase(name), property) - if nullable && goType != "" { - goType = "*" + goType + goTypeName = setObjectType(refBuffer, root, typeName+toPascalCase(name), property) + if nullable && goTypeName != "" { + goTypeName = "*" + goTypeName } } else { - goType = setGoType(root.Title, property) + goTypeName = setGoType(root.Title, property) } } - if goType == "" { + if goTypeName == "" { continue } @@ -202,7 +166,7 @@ func generateFields(refBuffer *bytes.Buffer, root *Schema, typeName string, s *S if !requiredFields[name] { tag += ",omitempty" } - fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(name), goType, tag) + fmt.Fprintf(&fields, "\t%s %s `json:\"%s\"`\n", toPascalCase(name), goTypeName, tag) } return fields.String() } @@ -220,52 +184,42 @@ func setObjectType(decls *bytes.Buffer, root *Schema, typeName string, s *Schema return typeName } -// discriminator returns the JSON key and string value of the first (sorted) property -// carrying a non-empty string const — the field that selects a oneOf variant. -func discriminator(s *Schema) (key, value string) { - names := make([]string, 0, len(s.Properties)) - for n := range s.Properties { - names = append(names, n) - } - sort.Strings(names) - for _, n := range names { - p := s.Properties[n] - if len(p.Const) == 0 { - continue - } - var v string - if err := json.Unmarshal(p.Const, &v); err == nil { - return n, v - } - } - return "", "" -} - // setUnionType emits a struct with one pointer field per variant and an UnmarshalJSON // for a oneOf union into decls. Returns the struct type name, or "" if no variant resolves. func setUnionType(decls *bytes.Buffer, root *Schema, typeName string, members []*Schema) string { - type variant struct{ goType, fieldName, disc string } + type variant struct{ goTypeName, fieldName, disc string } var variants []variant discKey := "" for _, m := range members { - if m.Ref == "" { - continue - } - goType, defKey := resolveReferences(root.Title, m.Ref) - def := root.Defs[defKey] - if def == nil { - continue - } - k, v := discriminator(def) - if k == "" { - continue + if m.Ref != "" { + goTypeName := resolveReferences(root.Title, m.Ref) + defKey := refDefKey(m.Ref) + def := root.Defs[defKey] + if def == nil { + continue + } + names := make([]string, 0, len(def.Properties)) + for n := range def.Properties { + names = append(names, n) + } + sort.Strings(names) + k, v := "", "" + for _, n := range names { + if cv, ok := constValue(def.Properties[n]); ok { + k, v = n, cv + break + } + } + if k == "" { + continue + } + discKey = k + variants = append(variants, variant{ + goTypeName: goTypeName, + fieldName: toPascalCase(defKey), + disc: v, + }) } - discKey = k - variants = append(variants, variant{ - goType: goType, - fieldName: toPascalCase(defKey), - disc: v, - }) } if len(variants) == 0 { return "" @@ -273,7 +227,7 @@ func setUnionType(decls *bytes.Buffer, root *Schema, typeName string, members [] fmt.Fprintf(decls, "type %s struct {\n", typeName) for _, v := range variants { - fmt.Fprintf(decls, "\t%s *%s\n", v.fieldName, v.goType) + fmt.Fprintf(decls, "\t%s *%s\n", v.fieldName, v.goTypeName) } fmt.Fprintf(decls, "}\n\n") @@ -285,7 +239,7 @@ func setUnionType(decls *bytes.Buffer, root *Schema, typeName string, members [] fmt.Fprintf(decls, "\tswitch disc.%s {\n", field) for _, v := range variants { fmt.Fprintf(decls, "\tcase %q:\n\t\tc.%s = &%s{}\n\t\treturn json.Unmarshal(data, c.%s)\n", - v.disc, v.fieldName, v.goType, v.fieldName) + v.disc, v.fieldName, v.goTypeName, v.fieldName) } fmt.Fprintf(decls, "\tdefault:\n\t\treturn fmt.Errorf(\"unknown %s %%q\", disc.%s)\n\t}\n}\n\n", discKey, field) return typeName @@ -296,14 +250,13 @@ func setUnionType(decls *bytes.Buffer, root *Schema, typeName string, members [] // Handles both single-string types ("string") and nullable pairs (["string","null"]). func setGoType(root string, s *Schema) string { if s.Ref != "" { - gt, _ := resolveReferences(root, s.Ref) - return gt + return resolveReferences(root, s.Ref) } if s.Type == nil { return "" } - var single string - if err := json.Unmarshal(s.Type, &single); err == nil && single == ARRAY_TYPE { + var typeName string + if err := json.Unmarshal(s.Type, &typeName); err == nil && typeName == ARRAY_TYPE { return setArrayType(root, s) } return setPrimitiveType(s) @@ -315,11 +268,11 @@ func setArrayType(root string, s *Schema) string { if s.Items == nil { return "" } - elem := setGoType(root, s.Items) - if elem == "" { + goTypeName := setGoType(root, s.Items) + if goTypeName == "" { return "" } - return "[]" + elem + return "[]" + goTypeName } // setEnumType emits a named string type and a const block for a string enum into decls, @@ -348,20 +301,19 @@ func setEnumType(decls *bytes.Buffer, schemaName, property string, values []json return typeName } -// resolveReferences maps a $ref to its Go type name and the bare ref name. A local ref -// ("#/$defs/RichTextNode") PascalCases the segment after the last "/" and prefixes the root schema -// title → ("ArticleRichTextNode", "RichTextNode"). A cross-schema ref ("Category.json") strips the -// path and ".json" suffix → ("Category", "Category"). The bare name is the raw def key, so callers -// can index root.Defs with it; PascalCasing is left to callers. Empty → ("", ""). -func resolveReferences(root, ref string) (goType, name string) { - if ref == "" { - return "", "" +// resolveReferences maps a $ref to its Go type name. A local ref ("#/$defs/RichTextNode") +// PascalCases the segment after the last "/" and prefixes the root schema title → +// "ArticleRichTextNode". A cross-schema ref ("Category.json") strips the path and ".json" +// suffix → "Category". Empty → "". +func resolveReferences(root, ref string) string { + name := refDefKey(ref) + if name == "" { + return "" } - name = strings.TrimSuffix(filepath.Base(ref), ".json") if strings.HasPrefix(ref, "#") { - return root + toPascalCase(name), name + return root + toPascalCase(name) } - return name, name + return name } // setPrimitiveType returns the Go type string for a schema node, or "" if the type cannot diff --git a/internal/generator/helpers.go b/internal/generator/helpers.go index c072a38..27e35db 100644 --- a/internal/generator/helpers.go +++ b/internal/generator/helpers.go @@ -1,6 +1,8 @@ package generator import ( + "encoding/json" + "path/filepath" "strings" "unicode" ) @@ -23,3 +25,63 @@ func toPascalCase(s string) string { } return b.String() } + +// refDefKey strips the path and ".json" suffix from a $ref, yielding the raw $defs key +// callers can index root.Defs with. "#/$defs/RichTextNode" → "RichTextNode"; +// "Category.json" → "Category". Empty → "". +func refDefKey(ref string) string { + if ref == "" { + return "" + } + return strings.TrimSuffix(filepath.Base(ref), ".json") +} + +// constValue returns the schema node's const as a string and true, or ("", false) +// if the node has no const or its const is not a JSON string. +func constValue(s *Schema) (string, bool) { + if len(s.Const) == 0 { + return "", false + } + var v string + if err := json.Unmarshal(s.Const, &v); err != nil { + return "", false + } + return v, true +} + +// objectType reports whether s is an inline object and whether it is nullable +// (type ["object","null"]). Single "object" → (true,false); ["object","null"] → +// (true,true); anything else → (false,false). $ref and oneOf nodes carry no "type" +// and so are never treated as inline objects (handled in later steps). +func objectType(s *Schema) (isObject, nullable bool) { + var single string + if err := json.Unmarshal(s.Type, &single); err == nil { + return single == "object", false + } + var multi []string + if err := json.Unmarshal(s.Type, &multi); err == nil && len(multi) == 2 { + var hasObject, hasNull bool + for _, t := range multi { + switch t { + case "object": + hasObject = true + case "null": + hasNull = true + } + } + // it looks redundant to return the same value twice, but the first return is for isObject and the second is for nullable + // but we are evaluating this scenario: ["object", "null"] which means it is an object and it is nullable. + return hasObject && hasNull, hasObject && hasNull + } + return false, false +} + +// hasOneOf reports whether any top-level property of s uses oneOf. +func hasOneOf(s *Schema) bool { + for _, p := range s.Properties { + if len(p.OneOf) > 0 { + return true + } + } + return false +} diff --git a/model/article.go b/model/article.go index d8be736..95c73b1 100644 --- a/model/article.go +++ b/model/article.go @@ -25,13 +25,13 @@ func (c *ArticleContent) UnmarshalJSON(data []byte) error { if string(data) == "null" { return nil } - var disc struct { + var discriminator struct { Format string `json:"format"` } - if err := json.Unmarshal(data, &disc); err != nil { + if err := json.Unmarshal(data, &discriminator); err != nil { return err } - switch disc.Format { + switch discriminator.Format { case "richtext": c.RichText = &ArticleRichText{} return json.Unmarshal(data, c.RichText) @@ -39,7 +39,7 @@ func (c *ArticleContent) UnmarshalJSON(data []byte) error { c.PlainText = &ArticlePlainText{} return json.Unmarshal(data, c.PlainText) default: - return fmt.Errorf("unknown format %q", disc.Format) + return fmt.Errorf("unknown format %q", discriminator.Format) } } From f753b79292537669b845d337083cdac81f4fbdf0 Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Mon, 29 Jun 2026 06:46:58 +0200 Subject: [PATCH 8/9] feat: add a more robust test cases --- docs/step-11-schema-fidelity.md | 51 ++++++++++ docs/step-12-enum-validation.md | 171 ++++++++++++++++++++++++++++++++ fidelity_test.go | 98 ++++++++++++++++++ internal/generator/generator.go | 35 ++++++- internal/generator/helpers.go | 10 -- model/article.go | 25 ++++- model/category.go | 22 ++++ model/product.go | 39 ++++++++ 8 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 docs/step-11-schema-fidelity.md create mode 100644 docs/step-12-enum-validation.md create mode 100644 fidelity_test.go diff --git a/docs/step-11-schema-fidelity.md b/docs/step-11-schema-fidelity.md new file mode 100644 index 0000000..d968377 --- /dev/null +++ b/docs/step-11-schema-fidelity.md @@ -0,0 +1,51 @@ +# Step 11 — Schema Fidelity: Documenting Runtime Gaps + +## Context + +The README notes: + +> "It's possible to make all tests pass even though the generated Go code is still missing a lot of necessary information." + +Steps 01–10 produce structurally correct Go types and all 8 harness subtests pass. However, the smoke tests only verify that `json.Unmarshal` does not error on **valid** payloads. They give no signal when **invalid** JSON is silently accepted. + +This step adds no generator changes. Instead it introduces `fidelity_test.go` — a test file that documents the runtime gaps between "tests pass" and "schema is fully enforced." + +--- + +## Fidelity Gaps + +| Gap | Schema requires | Go behaviour today | +|-----|----------------|--------------------| +| Missing required field | Error — `id`/`name`/`price` required in `Product` | Silent zero-value (`Id = ""`) | +| Missing required field | Error — `id`/`title` required in `Article` | Silent zero-value (`Title = ""`) | +| Invalid enum value | Error — `CategoryKind` has exactly 4 valid values | ~~Any string accepted silently~~ — **closed in [step 12](step-12-enum-validation.md)** | +| `null` vs absent | Distinct in JSON Schema | Both resolve to same Go zero/nil after unmarshal | + +**Trade-off accepted (at step 11):** Required field enforcement and enum validation are not emitted by the generator. The Go type system represents the shape; runtime constraint checking would require custom `UnmarshalJSON` on every affected type. + +> **Update:** The invalid-enum gap is **closed** in [step 12](step-12-enum-validation.md), which makes the generator emit a generic `UnmarshalJSON` on every enum type. The required-field and `null` vs absent gaps remain open. + +--- + +## Test File: `fidelity_test.go` + +Lives in the root package alongside `harness_test.go`. + +### Part 1 — Generator pre-check + +`TestGeneratorRuns` shells out to `go run ./cmd` and asserts exit code 0. If the generator itself is broken, the rest of the suite is meaningless. + +### Part 2 — Fidelity gap tests + +`TestSchemaFidelityGaps` exercises invalid JSON against the generated types and asserts the **current** (gap) behaviour. Each subtest carries a `// schema says:` comment describing what a fully-enforced implementation would do. + +Tests are written so they **pass today** and **break automatically** if the generator is later enhanced to enforce the constraint — the `t.Fatalf` inside each subtest fires when the gap is closed (e.g. an error is returned where none was expected before). + +--- + +## Done When + +- `go test ./...` passes including `TestGeneratorRuns` and `TestSchemaFidelityGaps` +- `go vet ./...` is clean +- Each gap subtest name appears in the test output +- No generator or model files are modified diff --git a/docs/step-12-enum-validation.md b/docs/step-12-enum-validation.md new file mode 100644 index 0000000..56668f6 --- /dev/null +++ b/docs/step-12-enum-validation.md @@ -0,0 +1,171 @@ +# Step 12 — Enum Validation at Unmarshal Time + +## Goal + +Close the **invalid-enum** fidelity gap documented in +[step-11](step-11-schema-fidelity.md). Step 05 emits each string enum as a named `string` +type with a `const` block, but because the underlying kind is plain `string`, the standard +`encoding/json` decoder silently accepts **any** value — `{"kind":"bogus"}` unmarshals +cleanly into `model.Category`. + +This step makes the generator emit an `UnmarshalJSON` method on every enum type that +**rejects values outside the schema's enum set**. The implementation is **generic**: it is +driven entirely by the generated constants, so it applies automatically to every existing +enum and to any enum introduced by a future schema — no per-type, hand-written code. + +## Inputs + +Same schemas as step 05. Every string enum in scope gains validation: + +| Enum field | Generated type | Valid values | +|----------------------------|-------------------------|----------------------------------------------------------| +| `Product.classification` | `ProductClassification` | `basic_science`, `basic-science`, `applied`, `experimental` | +| `Product.dimensions.unit` | `ProductDimensionsUnit` | `cm`, `inch` | +| `Category.kind` | `CategoryKind` | `basic-science`, `basic_science`, `clinical`, `other` | +| `Article.status` | `ArticleStatus` | `draft`, `published`, `archived` | + +Nested enums (e.g. `Product.dimensions.unit`) are covered too — they flow through the same +`setEnumType` path, so the method is emitted regardless of nesting depth. + +## Behaviour contract + +For a generated enum type `T`: + +- **Valid value** → assigned to the receiver, no error. +- **Invalid value** → `fmt.Errorf("invalid T %q", s)`. +- **JSON `null`** → treated as absent: the receiver keeps its zero value and no error is + returned. This mirrors the `oneOf` union `UnmarshalJSON` (step 09) and avoids erroring on + an explicit `null` for a non-nullable enum. +- **Empty string `""`** → rejected, because it is not a member of any enum set. + +## Implementation + +### `setEnumUnmarshal` in `internal/generator/generator.go` + +`setEnumType` already builds the constant identifiers; it now also collects them into a +slice and, when at least one exists, delegates to a new helper that emits the method: + +```go +// setEnumUnmarshal emits an UnmarshalJSON method that rejects any value outside the enum's +// constant set, turning silent acceptance of invalid values into an error. It is fully +// generic: it references the generated constants, so it works for any string enum from any +// schema. A JSON null is treated as absent (leaves the zero value), mirroring setUnionType. +func setEnumUnmarshal(decls *bytes.Buffer, typeName string, constNames []string) { + fmt.Fprintf(decls, "\nfunc (e *%s) UnmarshalJSON(data []byte) error {\n", typeName) + fmt.Fprintf(decls, "\tif string(data) == \"null\" {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(decls, "\tvar s string\n") + fmt.Fprintf(decls, "\tif err := json.Unmarshal(data, &s); err != nil {\n\t\treturn err\n\t}\n") + fmt.Fprintf(decls, "\tswitch %s(s) {\n", typeName) + fmt.Fprintf(decls, "\tcase %s:\n", strings.Join(constNames, ", ")) + fmt.Fprintf(decls, "\t\t*e = %s(s)\n\t\treturn nil\n", typeName) + fmt.Fprintf(decls, "\tdefault:\n\t\treturn fmt.Errorf(\"invalid %s %%q\", s)\n\t}\n}\n", typeName) +} +``` + +### Why a pointer receiver works on value fields + +The method has a pointer receiver (`*T`), yet enum struct fields are plain values +(`Kind CategoryKind`, not `*CategoryKind`). This is fine: during `json.Unmarshal` the parent +struct is addressable, so the decoder calls `UnmarshalJSON` on `&c.Kind`. No field needs to +become a pointer. + +### Content-based import detection in `Generate()` + +The emitted method references `encoding/json` and `fmt`. Previously imports were added only +for schemas containing a `oneOf`. That check is replaced with detection based on whether the +generated code actually references each package, so enum-only files (e.g. `category.go`, +`product.go`) get the imports they now need: + +```go +generated := body + refBuf.String() +var imports []string +if strings.Contains(generated, "json.") { + imports = append(imports, "\t\"encoding/json\"") +} +if strings.Contains(generated, "fmt.") { + imports = append(imports, "\t\"fmt\"") +} +``` + +Struct tags use `json:"..."` (colon), not `json.`, so they never produce a false positive. +The now-unused `hasOneOf` helper is removed. + +## Output + +### model/category.go + +```go +package model + +import ( + "encoding/json" + "fmt" +) + +type Category struct { + Id string `json:"id,omitempty"` + Kind CategoryKind `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Parent *string `json:"parent,omitempty"` +} + +type CategoryKind string + +const ( + CategoryKindBasicScience CategoryKind = "basic-science" + CategoryKindBasicScience2 CategoryKind = "basic_science" + CategoryKindClinical CategoryKind = "clinical" + CategoryKindOther CategoryKind = "other" +) + +func (e *CategoryKind) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch CategoryKind(s) { + case CategoryKindBasicScience, CategoryKindBasicScience2, CategoryKindClinical, CategoryKindOther: + *e = CategoryKind(s) + return nil + default: + return fmt.Errorf("invalid CategoryKind %q", s) + } +} +``` + +`model/product.go` gains the same method for both `ProductClassification` and +`ProductDimensionsUnit`; `model/article.go` for `ArticleStatus`. + +## CLI invocation + +```sh +go run ./cmd +``` + +## Done when + +- [ ] `go run ./cmd` exits 0 and regenerates the model files +- [ ] Every generated enum type declares an `UnmarshalJSON` method, including nested enums + (`ProductDimensionsUnit`) +- [ ] Enum-bearing files import `encoding/json` and `fmt`; files without enums/unions import + neither +- [ ] `go vet ./...` is clean (no unused imports or helpers) +- [ ] `go test ./...` passes: the 8 harness subtests still pass (valid payloads unaffected), + and the enum subtests in `fidelity_test.go` now assert rejection of invalid values +- [ ] Repeated runs produce byte-identical output + +## Error handling contract + +Inherits step 05. New: unmarshalling an enum value outside its set returns +`fmt.Errorf("invalid %q", value)`. `null` is a no-op. The contract is the same for +every enum, current or future. + +## Out of scope + +- Required-field enforcement and the `null` vs absent distinction — still open gaps in + [step-11](step-11-schema-fidelity.md) +- Non-string enums (integer/number) +- `MarshalJSON` (marshalling is unchanged; a typed value already serialises as its string) diff --git a/fidelity_test.go b/fidelity_test.go new file mode 100644 index 0000000..51459b7 --- /dev/null +++ b/fidelity_test.go @@ -0,0 +1,98 @@ +package challenge + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + + "github.com/amboss-mededu/go-coding-challenge/model" +) + +// TestMain runs the generator before any test in this package executes. +func TestMain(m *testing.M) { + cmd := exec.Command("go", "run", "./cmd") + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "generator failed: %v\n%s", err, out) + os.Exit(1) + } + os.Exit(m.Run()) +} + +// TestSchemaFidelityGaps documents the gap between "tests pass" and +// "schema is fully enforced at runtime". Each subtest shows current Go +// behaviour; the "schema says" comment describes what a fully faithful +// implementation would do. Tests break automatically if the generator is +// later enhanced to enforce the constraint. +func TestSchemaFidelityGaps(t *testing.T) { + t.Run("product should have required id", func(t *testing.T) { + var p model.Product + err := json.Unmarshal([]byte(`{"name":"x","price":1.0}`), &p) + if err != nil { + t.Fatalf("gap closed: expected no error for missing required field, got: %v", err) + } + // gap: Id is "" instead of an error + if p.Id != "" { + t.Fatalf("expected empty Id (gap), got %q", p.Id) + } + }) + + t.Run("product should have required price", func(t *testing.T) { + // schema says: required field "price" must be present — absence is an error + var p model.Product + err := json.Unmarshal([]byte(`{"id":"1","name":"x"}`), &p) + if err != nil { + t.Fatalf("gap closed: expected no error for missing required field, got: %v", err) + } + if p.Price != 0 { + t.Fatalf("expected zero Price (gap), got %v", p.Price) + } + }) + + t.Run("article should have required title", func(t *testing.T) { + // schema says: required field "title" must be present — absence is an error + var a model.Article + err := json.Unmarshal([]byte(`{"id":"1"}`), &a) + if err != nil { + t.Fatalf("gap closed: expected no error for missing required field, got: %v", err) + } + // gap: Title is "" instead of an error + if a.Title != "" { + t.Fatalf("expected empty Title (gap), got %q", a.Title) + } + }) + + t.Run("category should reject invalid enum kind", func(t *testing.T) { + // schema says: kind must be one of basic-science|basic_science|clinical|other + var c model.Category + if err := json.Unmarshal([]byte(`{"kind":"bogus"}`), &c); err == nil { + t.Fatalf("expected error for invalid enum value, got none (Kind=%q)", c.Kind) + } + + // a valid enum value must still be accepted + if err := json.Unmarshal([]byte(`{"kind":"clinical"}`), &c); err != nil { + t.Fatalf("expected valid enum value to be accepted, got: %v", err) + } + if c.Kind != model.CategoryKindClinical { + t.Fatalf("expected Kind=clinical, got %q", c.Kind) + } + }) + + t.Run("product should reject invalid enum classification", func(t *testing.T) { + // schema says: classification must be one of basic_science|basic-science|applied|experimental + var p model.Product + if err := json.Unmarshal([]byte(`{"id":"1","name":"x","price":1.0,"classification":"unknown"}`), &p); err == nil { + t.Fatalf("expected error for invalid enum value, got none (Classification=%q)", p.Classification) + } + + // a valid enum value must still be accepted + if err := json.Unmarshal([]byte(`{"id":"1","name":"x","price":1.0,"classification":"applied"}`), &p); err != nil { + t.Fatalf("expected valid enum value to be accepted, got: %v", err) + } + if p.Classification != model.ProductClassificationApplied { + t.Fatalf("expected Classification=applied, got %q", p.Classification) + } + }) +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3888a4e..6c0693e 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -96,9 +96,19 @@ func (g *Generator) Generate() (map[string][]byte, error) { setObjectType(&refBuf, mainSchema, mainSchema.Title+toPascalCase(name), mainSchema.Defs[name]) } + // Add imports only for packages the generated code actually references. Struct + // tags use json:"..." (colon), not "json.", so they don't trigger a false match. fileHeader := "package model\n\n" - if hasOneOf(mainSchema) { - fileHeader += "import (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n" + generated := body + refBuf.String() + var imports []string + if strings.Contains(generated, "json.") { + imports = append(imports, "\t\"encoding/json\"") + } + if strings.Contains(generated, "fmt.") { + imports = append(imports, "\t\"fmt\"") + } + if len(imports) > 0 { + fileHeader += "import (\n" + strings.Join(imports, "\n") + "\n)\n\n" } fmt.Fprintf(&buf, "%stype %s struct {\n%s}\n", fileHeader, mainSchema.Title, body) @@ -283,6 +293,7 @@ func setEnumType(decls *bytes.Buffer, schemaName, property string, values []json typeName := schemaName + toPascalCase(property) var consts bytes.Buffer seen := make(map[string]bool) + var constNames []string for _, raw := range values { var v string if err := json.Unmarshal(raw, &v); err != nil { @@ -294,13 +305,33 @@ func setEnumType(decls *bytes.Buffer, schemaName, property string, values []json name = fmt.Sprintf("%s%d", base, i) } seen[name] = true + constNames = append(constNames, name) fmt.Fprintf(&consts, "\t%s %s = %q\n", name, typeName, v) } fmt.Fprintf(decls, "type %s string\n\nconst (\n%s)\n", typeName, consts.String()) + + if len(constNames) > 0 { + setEnumUnmarshal(decls, typeName, constNames) + } return typeName } +// setEnumUnmarshal emits an UnmarshalJSON method that rejects any value outside the enum's +// constant set, turning silent acceptance of invalid values into an error. It is fully +// generic: it references the generated constants, so it works for any string enum from any +// schema. A JSON null is treated as absent (leaves the zero value), mirroring setUnionType. +func setEnumUnmarshal(decls *bytes.Buffer, typeName string, constNames []string) { + fmt.Fprintf(decls, "\nfunc (e *%s) UnmarshalJSON(data []byte) error {\n", typeName) + fmt.Fprintf(decls, "\tif string(data) == \"null\" {\n\t\treturn nil\n\t}\n") + fmt.Fprintf(decls, "\tvar s string\n") + fmt.Fprintf(decls, "\tif err := json.Unmarshal(data, &s); err != nil {\n\t\treturn err\n\t}\n") + fmt.Fprintf(decls, "\tswitch %s(s) {\n", typeName) + fmt.Fprintf(decls, "\tcase %s:\n", strings.Join(constNames, ", ")) + fmt.Fprintf(decls, "\t\t*e = %s(s)\n\t\treturn nil\n", typeName) + fmt.Fprintf(decls, "\tdefault:\n\t\treturn fmt.Errorf(\"invalid %s %%q\", s)\n\t}\n}\n", typeName) +} + // resolveReferences maps a $ref to its Go type name. A local ref ("#/$defs/RichTextNode") // PascalCases the segment after the last "/" and prefixes the root schema title → // "ArticleRichTextNode". A cross-schema ref ("Category.json") strips the path and ".json" diff --git a/internal/generator/helpers.go b/internal/generator/helpers.go index 27e35db..f39e90c 100644 --- a/internal/generator/helpers.go +++ b/internal/generator/helpers.go @@ -75,13 +75,3 @@ func objectType(s *Schema) (isObject, nullable bool) { } return false, false } - -// hasOneOf reports whether any top-level property of s uses oneOf. -func hasOneOf(s *Schema) bool { - for _, p := range s.Properties { - if len(p.OneOf) > 0 { - return true - } - } - return false -} diff --git a/model/article.go b/model/article.go index 95c73b1..f761800 100644 --- a/model/article.go +++ b/model/article.go @@ -25,13 +25,13 @@ func (c *ArticleContent) UnmarshalJSON(data []byte) error { if string(data) == "null" { return nil } - var discriminator struct { + var disc struct { Format string `json:"format"` } - if err := json.Unmarshal(data, &discriminator); err != nil { + if err := json.Unmarshal(data, &disc); err != nil { return err } - switch discriminator.Format { + switch disc.Format { case "richtext": c.RichText = &ArticleRichText{} return json.Unmarshal(data, c.RichText) @@ -39,7 +39,7 @@ func (c *ArticleContent) UnmarshalJSON(data []byte) error { c.PlainText = &ArticlePlainText{} return json.Unmarshal(data, c.PlainText) default: - return fmt.Errorf("unknown format %q", discriminator.Format) + return fmt.Errorf("unknown format %q", disc.Format) } } @@ -61,6 +61,23 @@ const ( ArticleStatusArchived ArticleStatus = "archived" ) +func (e *ArticleStatus) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch ArticleStatus(s) { + case ArticleStatusDraft, ArticleStatusPublished, ArticleStatusArchived: + *e = ArticleStatus(s) + return nil + default: + return fmt.Errorf("invalid ArticleStatus %q", s) + } +} + type ArticlePlainText struct { Format string `json:"format,omitempty"` Text string `json:"text,omitempty"` diff --git a/model/category.go b/model/category.go index 84ef2e1..f8317e6 100644 --- a/model/category.go +++ b/model/category.go @@ -1,5 +1,10 @@ package model +import ( + "encoding/json" + "fmt" +) + type Category struct { Id string `json:"id,omitempty"` Kind CategoryKind `json:"kind,omitempty"` @@ -15,3 +20,20 @@ const ( CategoryKindClinical CategoryKind = "clinical" CategoryKindOther CategoryKind = "other" ) + +func (e *CategoryKind) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch CategoryKind(s) { + case CategoryKindBasicScience, CategoryKindBasicScience2, CategoryKindClinical, CategoryKindOther: + *e = CategoryKind(s) + return nil + default: + return fmt.Errorf("invalid CategoryKind %q", s) + } +} diff --git a/model/product.go b/model/product.go index 0ff364a..05b1cca 100644 --- a/model/product.go +++ b/model/product.go @@ -1,5 +1,10 @@ package model +import ( + "encoding/json" + "fmt" +) + type Product struct { Active bool `json:"active,omitempty"` Classification ProductClassification `json:"classification,omitempty"` @@ -21,6 +26,23 @@ const ( ProductClassificationExperimental ProductClassification = "experimental" ) +func (e *ProductClassification) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch ProductClassification(s) { + case ProductClassificationBasicScience, ProductClassificationBasicScience2, ProductClassificationApplied, ProductClassificationExperimental: + *e = ProductClassification(s) + return nil + default: + return fmt.Errorf("invalid ProductClassification %q", s) + } +} + type ProductDimensionsUnit string const ( @@ -28,6 +50,23 @@ const ( ProductDimensionsUnitInch ProductDimensionsUnit = "inch" ) +func (e *ProductDimensionsUnit) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + switch ProductDimensionsUnit(s) { + case ProductDimensionsUnitCm, ProductDimensionsUnitInch: + *e = ProductDimensionsUnit(s) + return nil + default: + return fmt.Errorf("invalid ProductDimensionsUnit %q", s) + } +} + type ProductDimensions struct { Height float64 `json:"height,omitempty"` Unit ProductDimensionsUnit `json:"unit,omitempty"` From f48828e703ff5200b7fbeda7f7de5b379aa6bcea Mon Sep 17 00:00:00 2001 From: Jose Montero Date: Mon, 29 Jun 2026 06:52:20 +0200 Subject: [PATCH 9/9] feat: add README file --- Makefile | 3 ++ docs/README.md | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 docs/README.md diff --git a/Makefile b/Makefile index 09efeca..2f4f036 100644 --- a/Makefile +++ b/Makefile @@ -9,3 +9,6 @@ test: generate: ./bin/generator + +make deps: + go mod tidy diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2fea14f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,88 @@ +# Schema-Driven Code Generator — Solution + +## How to Run + +**Prerequisites:** Go 1.23+ + +```sh +# Install dependencies +make deps + +# Generate model files (writes model/product.go, model/category.go, model/article.go) +make run + +# Build the binary, then generate the go structs via the compiled binary +make build +make generate + +# Run all tests (TestMain re-runs the generator automatically before each test run) +make test +``` + +To override the default input/output directories, run the generator directly: + +```sh +go run ./cmd --schemas ./schemas --output ./model +``` + +All tests pass. `go test ./...` covers 8 smoke-test subtests (valid payloads round-trip cleanly) and 5 fidelity subtests (invalid values are correctly rejected or accepted per documented behavior). + +--- + +## Decisions and Why + +### One file per schema, local definitions co-located + +Each top-level schema produces one `.go` file. Types defined in `$defs` (e.g. `ArticleRichText`, `ArticleRichTextNode`) are emitted into the same file as their parent schema. This keeps related types together and avoids any cross-file dependency issues. + +### Optional fields as pointer types + +Required fields get a plain value type. Optional and nullable fields get a pointer (`*T`). This gives callers a clear nil/non-nil signal and is idiomatic Go — no wrapper structs or sentinel values needed. + +### Enums as named string types with a const block + +Each enum field generates a named `string` type (e.g. `CategoryKind`) and a `const` block of typed values. This is type-safe and serialises to the raw JSON string without any custom `MarshalJSON`. An `UnmarshalJSON` method is also emitted on every enum type so that invalid values are rejected at decode time rather than silently accepted. + +### `oneOf` as a struct-of-pointers with a generated `UnmarshalJSON` + +Each `oneOf` union produces a holding struct where each variant is a pointer field. A generated `UnmarshalJSON` peeks at the discriminator field (found by scanning the variant's `const` property at codegen time) and populates exactly one pointer. Callers check which pointer is non-nil to know which variant arrived. + +### `$ref` resolution at codegen time + +References are resolved when generating the source, not at runtime. A local `$defs` ref becomes the corresponding Go type name; a cross-schema ref becomes the target schema's type. No runtime lookup or indirection. + +### Deterministic output + +All property maps and `$defs` maps are sorted before iteration. Repeated runs of the generator produce byte-identical output, which keeps version-control diffs clean. + +--- + +## Walkthrough — Approach and Trade-offs + +The implementation grew in 12 documented steps (one spec file per step in `docs/`). Each step added one schema concept and left `go test ./...` passing before moving on: + +| Steps | Concepts covered | +|-------|-----------------| +| 01–03 | Empty structs → primitive fields → nullable pointer fields | +| 04–05 | Array fields → string enums | +| 06–07 | Inline nested objects → cross-schema `$ref` | +| 08–09 | Local `$defs` resolution → `oneOf` discriminated union | +| 10 | Internal refactor (no output change) | +| 11–12 | Fidelity gap documentation → enum validation via `UnmarshalJSON` | + +### Trade-offs accepted + +**Required fields are not enforced at unmarshal time.** Go's `encoding/json` silently assigns zero values to absent fields. Generating correct required-field checks would need a custom `UnmarshalJSON` on every struct that has required fields. The complexity vs. benefit trade-off didn't justify it within the time budget. The gap is documented and tested in `fidelity_test.go`. + +**`null` vs absent is not distinguished.** JSON Schema treats an explicit `null` and a missing key differently; Go's decoder collapses both to nil/zero. Closing this gap would add noise to every generated type. Left open as a documented gap. + +**`MarshalJSON` for `oneOf` unions is not emitted.** The holding struct currently serialises as a JSON object with all variant fields present (most nil). A correct `MarshalJSON` would delegate to the active variant. This is a round-trip fidelity gap. + +**Non-string enums are not covered.** The enum emitter assumes string values; none of the provided schemas use integer or number enums. + +### What I'd add with more time + +- **Required field enforcement** — generate `UnmarshalJSON` on structs with required fields; the `Schema.Required` slice is already parsed, so this is purely an emitter addition. +- **`MarshalJSON` for `oneOf`** — emit a method that checks which variant pointer is non-nil and delegates serialisation to it. +- **Generator unit tests** — the current test suite validates generated output but not the generator's internal functions. Table-driven tests for helpers like `setPrimitiveType`, `setEnumType`, and `resolveReferences` would make the emitter safer to change. +- **Non-string enum support** — extend the enum emitter to handle `integer` and `number` enum schemas.