Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ tags
tags
result
.direnv

# Ignore vendor directory
vendor/
# Ignore generated files
bin/
14 changes: 14 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
run:
go run ./cmd

build:
go build -o bin/generator ./cmd

test:
go clean -testcache && go test ./...

generate:
./bin/generator

make deps:
go mod tidy
5 changes: 5 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package main

func main() {
Execute()
}
55 changes: 55 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 88 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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.
61 changes: 61 additions & 0 deletions docs/step-00-plan.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions docs/step-01-empty-structs.md
Original file line number Diff line number Diff line change
@@ -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
Loading