Complete package for generating ocds structs (with extensions) in golang

This commit is contained in:
bwilliams committed 2026-10-01 14:50:07 +01:00
1 parent da726550d4
commit 35974fa070
47 files changed
+9861 -299

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// run executes the command tree with args and returns stdout, stderr and the error.
func run(t *testing.T, args ...string) (string, string, error) {
t.Helper()
var stdout, stderr bytes.Buffer
cmd := NewRootCmd()
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs(args)
err := cmd.Execute()
return stdout.String(), stderr.String(), err
}
func TestGenerate(t *testing.T) {
out := filepath.Join(t.TempDir(), "tenders", "ocds_gen.go")
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
t.Fatal(err)
}
if _, stderr, err := run(t, "generate", "-c", "testdata/widgets.json", "-o", out); err != nil {
t.Fatalf("generate: %v\n%s", err, stderr)
}
raw, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
// gofmt aligns struct columns, so compare with whitespace collapsed.
src := strings.Join(strings.Fields(string(raw)), " ")
for _, want := range []string{
"package tenders", // derived from the output directory
"Date time.Time", // required date-time is not a pointer
"ID ID `json:\"id\"`", // ["string","integer"]
"Widgets []Widget", // extension field on a core type
"Count *int", // extension definition
"Releases []Release", // external $ref to the release schema
"Widgets (declared)", // header records resolution
"Orphan (dependency)",
} {
if !strings.Contains(src, want) {
t.Errorf("generated code is missing %q", want)
}
}
}
func TestGenerateStdoutNeedsPackage(t *testing.T) {
_, _, err := run(t, "generate", "-c", "testdata/core.json", "-o", "-")
if err == nil || !strings.Contains(err.Error(), "--package") {
t.Fatalf("want --package error, got %v", err)
}
stdout, _, err := run(t, "generate", "-c", "testdata/core.json", "-o", "-", "-p", "core")
if err != nil || !strings.HasPrefix(stdout, "// Code generated") {
t.Fatalf("stdout generation failed: %v", err)
}
}
func TestCheck(t *testing.T) {
out := filepath.Join(t.TempDir(), "ocds_gen.go")
args := []string{"generate", "-q", "-c", "testdata/core.json", "-o", out, "-p", "core"}
if _, _, err := run(t, append(args, "--check")...); err == nil || !strings.Contains(err.Error(), "does not exist") {
t.Fatalf("missing file: want error, got %v", err)
}
if _, _, err := run(t, args...); err != nil {
t.Fatal(err)
}
if _, _, err := run(t, append(args, "--check")...); err != nil {
t.Fatalf("fresh file: want no error, got %v", err)
}
f, _ := os.OpenFile(out, os.O_APPEND|os.O_WRONLY, 0)
f.WriteString("// hand edit\n")
f.Close()
if _, _, err := run(t, append(args, "--check")...); err == nil || !strings.Contains(err.Error(), "out of date") {
t.Fatalf("edited file: want out-of-date error, got %v", err)
}
}
func TestStrict(t *testing.T) {
out := filepath.Join(t.TempDir(), "ocds_gen.go")
args := []string{"generate", "-c", "testdata/widgets.json", "-o", out, "-p", "x"}
// Gadget is defined by the Orphan extension but nothing refers to it.
_, stderr, err := run(t, args...)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(stderr, "definition Gadget is never referenced") {
t.Errorf("want unreachable warning, got stderr:\n%s", stderr)
}
if _, _, err := run(t, append(args, "--strict")...); err == nil {
t.Fatal("--strict: want error")
}
if _, stderr, _ := run(t, append(args, "-q")...); stderr != "" {
t.Errorf("--quiet: want no stderr, got:\n%s", stderr)
}
}
func TestResolveJSON(t *testing.T) {
stdout, _, err := run(t, "resolve", "-c", "testdata/widgets.json", "--json")
if err != nil {
t.Fatal(err)
}
var rows []struct {
Name string `json:"name"`
Declared bool `json:"declared"`
}
if err := json.Unmarshal([]byte(stdout), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].Name != "Orphan" || rows[0].Declared || rows[1].Name != "Widgets" || !rows[1].Declared {
t.Fatalf("want [Orphan (dependency), Widgets (declared)], got %+v", rows)
}
}
func TestSchema(t *testing.T) {
stdout, _, err := run(t, "schema", "-q", "-c", "testdata/widgets.json")
if err != nil {
t.Fatal(err)
}
// Key order is preserved, and the extension's patch is merged in.
if !strings.Contains(stdout, `"widgets"`) || strings.Index(stdout, `"ocid"`) > strings.Index(stdout, `"tender"`) {
t.Fatalf("unexpected merged schema:\n%s", stdout)
}
if _, _, err := run(t, "schema", "--kind", "record", "-c", "testdata/core.json"); err == nil {
t.Fatal("--kind record: want error")
}
}
func TestPackageFromPath(t *testing.T) {
for in, want := range map[string]string{
"internal/tenders/ocds_gen.go": "tenders",
"profiles/uk/ocds_gen.go": "uk",
"pkg/eu-profile/x.go": "euprofile",
} {
got, err := packageFromPath(in)
if err != nil || got != want {
t.Errorf("packageFromPath(%q) = %q, %v; want %q", in, got, err, want)
}
}
if _, err := packageFromPath("pkg/func/x.go"); err == nil {
t.Error("keyword directory: want error")
}
}
+106
View File
@@ -0,0 +1,106 @@
package cli
import (
"bytes"
"errors"
"fmt"
"go/token"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/spf13/cobra"
)
func newGenerateCmd(opts *options) *cobra.Command {
var (
out string
pkg string
check bool
excludeFieldDoc bool
)
cmd := &cobra.Command{
Use: "generate",
Short: "Generate Go types from the config",
Long: `Generate fetches the schemas and extensions named in the config and writes
the Go types to --out. Use --out - to write to stdout.
With --check, nothing is written; the command fails if the output file is
missing or differs from what would be generated. Use this in CI to catch
generated code that is stale or was edited by hand.`,
Example: ` ocdsgen generate -c extensions.json -o ocds_gen.go -p tenders
ocdsgen generate --check --strict
//go:generate go tool ocdsgen generate -c extensions.json -o ocds_gen.go -p tenders`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if pkg == "" {
var err error
if pkg, err = packageFromPath(out); err != nil {
return err
}
}
cfg, g, err := opts.setup(cmd)
if err != nil {
return err
}
src, err := g.Run(cfg, pkg, filepath.Base(opts.config), excludeFieldDoc)
if err != nil {
return err
}
switch {
case out == "-":
if _, err := cmd.OutOrStdout().Write(src); err != nil {
return err
}
case check:
existing, err := os.ReadFile(out)
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%s does not exist; run ocdsgen generate", out)
} else if err != nil {
return err
}
if !bytes.Equal(existing, src) {
return fmt.Errorf("%s is out of date; run ocdsgen generate", out)
}
fmt.Fprintf(cmd.ErrOrStderr(), "%s is up to date\n", out)
default:
if err := os.WriteFile(out, src, 0o644); err != nil {
return err
}
}
return opts.finish(g)
},
}
f := cmd.Flags()
f.StringVarP(&out, "out", "o", "ocds_gen.go", `output Go file, or "-" for stdout`)
f.StringVarP(&pkg, "package", "p", "", "Go package name (default: name of the output file's directory)")
f.BoolVar(&check, "check", false, "verify the output file is up to date instead of writing it")
f.BoolVar(&excludeFieldDoc, "exclude-field-doc", false, `exclude documentation for definition fields`)
return cmd
}
// packageFromPath derives a package name from the directory the output file
// will be written to, e.g. internal/tenders/ocds_gen.go -> tenders.
func packageFromPath(out string) (string, error) {
if out == "-" {
return "", errors.New("--package is required when writing to stdout")
}
abs, err := filepath.Abs(out)
if err != nil {
return "", err
}
dir := filepath.Base(filepath.Dir(abs))
name := strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
return unicode.ToLower(r)
}
return -1
}, dir)
if !token.IsIdentifier(name) || token.IsKeyword(name) {
return "", fmt.Errorf("can't derive a package name from directory %q; pass --package", dir)
}
return name, nil
}
+65
View File
@@ -0,0 +1,65 @@
package cli
import (
"encoding/json"
"fmt"
"text/tabwriter"
"github.com/spf13/cobra"
)
func newResolveCmd(opts *options) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "resolve",
Short: "List the extensions the config resolves to, in merge order",
Long: `Resolve fetches each extension in the config and its dependencies, and prints
them in the order their patches are applied. Dependencies come first. Nothing
is generated.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, g, err := opts.setup(cmd)
if err != nil {
return err
}
exts, err := g.Resolve(cfg.Extensions)
if err != nil {
return err
}
if asJSON {
type row struct {
Name string `json:"name"`
URL string `json:"url"`
Declared bool `json:"declared"`
Dependencies []string `json:"dependencies,omitempty"`
}
rows := make([]row, len(exts))
for i, e := range exts {
rows[i] = row{e.Name, e.URL, e.Declared, e.Dependencies}
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
if err := enc.Encode(rows); err != nil {
return err
}
} else {
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "#\tNAME\tSOURCE\tURL")
for i, e := range exts {
src := "dependency"
if e.Declared {
src = "declared"
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", i+1, e.Name, src, e.URL)
}
if err := w.Flush(); err != nil {
return err
}
}
return opts.finish(g)
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "print as JSON")
return cmd
}
+72
View File
@@ -0,0 +1,72 @@
// Package cli defines the ocdsgen command tree using Cobra. The commands are
// thin wrappers around internal/generator.
package cli
import (
"fmt"
"io"
"time"
"github.com/spf13/cobra"
"git.avsdev.uk/Go/go-ocds/internal/generator"
)
// options are the flags shared by every subcommand.
type options struct {
config string
timeout time.Duration
strict bool
quiet bool
}
// NewRootCmd builds the command tree. It returns a fresh tree on each call so
// tests can run commands in isolation.
func NewRootCmd() *cobra.Command {
opts := &options{}
root := &cobra.Command{
Use: "ocdsgen",
Short: "Generate Go types for OCDS releases with chosen extensions",
Long: `ocdsgen reads an extensions.json config listing the OCDS core schemas and
extension URLs, resolves extension dependencies, applies each extension's
schema patch in order, and generates Go structs from the result.`,
SilenceUsage: true, // don't print usage on runtime errors
SilenceErrors: true, // main prints the error once
}
pf := root.PersistentFlags()
pf.StringVarP(&opts.config, "config", "c", "extensions.json", "path to the extensions config file")
pf.DurationVar(&opts.timeout, "timeout", 60*time.Second, "timeout for each HTTP request")
pf.BoolVar(&opts.strict, "strict", false, "fail if the generator reports any warnings")
pf.BoolVarP(&opts.quiet, "quiet", "q", false, "don't print notes and warnings")
root.AddCommand(
newGenerateCmd(opts),
newResolveCmd(opts),
newSchemaCmd(opts),
)
return root
}
// setup loads the config and creates a Generator that logs to the command's
// stderr (or nowhere, with --quiet).
func (o *options) setup(cmd *cobra.Command) (*generator.Config, *generator.Generator, error) {
cfg, err := generator.LoadConfig(o.config)
if err != nil {
return nil, nil, err
}
var log io.Writer = cmd.ErrOrStderr()
if o.quiet {
log = io.Discard
}
return cfg, generator.New(log, o.timeout), nil
}
// finish enforces --strict once a command's work is done.
func (o *options) finish(g *generator.Generator) error {
if n := g.Warnings(); o.strict && n > 0 {
return fmt.Errorf("%d warning(s) reported and --strict is set", n)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
)
func newSchemaCmd(opts *options) *cobra.Command {
var (
kind string
out string
)
cmd := &cobra.Command{
Use: "schema",
Short: "Write the merged JSON Schema (core plus extensions)",
Long: `Schema writes the release or release package schema with every extension
in the config applied. This is the schema the Go types are generated from,
which is useful for debugging a config or validating data with other tools.`,
Example: ` ocdsgen schema > release-schema.json
ocdsgen schema --kind package -o release-package-schema.json`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if kind != "release" && kind != "package" {
return fmt.Errorf(`--kind must be "release" or "package", got %q`, kind)
}
cfg, g, err := opts.setup(cmd)
if err != nil {
return err
}
exts, err := g.Resolve(cfg.Extensions)
if err != nil {
return err
}
s, err := g.Merge(cfg, exts)
if err != nil {
return err
}
doc := s.Release
if kind == "package" {
doc = s.Package
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
if out == "-" {
_, err = cmd.OutOrStdout().Write(b)
} else {
err = os.WriteFile(out, b, 0o644)
}
if err != nil {
return err
}
return opts.finish(g)
},
}
f := cmd.Flags()
f.StringVar(&kind, "kind", "release", `which schema to write: "release" or "package"`)
f.StringVarP(&out, "out", "o", "-", `output file, or "-" for stdout`)
return cmd
}
+5
View File
@@ -0,0 +1,5 @@
{
"releaseSchema": "release-schema.json",
"releasePackageSchema": "release-package-schema.json",
"extensions": []
}
+1
View File
@@ -0,0 +1 @@
{"name": {"en": "Orphan"}, "schemas": ["release-schema.json"]}
+1
View File
@@ -0,0 +1 @@
{"definitions": {"Gadget": {"type": "object", "properties": {"name": {"type": "string"}}}}}
+7
View File
@@ -0,0 +1,7 @@
{
"type": "object",
"properties": {
"uri": {"type": "string", "format": "uri"},
"releases": {"type": "array", "items": {"$ref": "https://standard.open-contracting.org/schema/1__1__5/release-schema.json"}}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"type": "object",
"required": ["ocid", "id", "date"],
"properties": {
"ocid": {"type": "string", "description": "Open Contracting ID."},
"id": {"type": "string"},
"date": {"type": "string", "format": "date-time"},
"tender": {"$ref": "#/definitions/Tender"}
},
"definitions": {
"Tender": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": ["string", "integer"]},
"title": {"type": ["string", "null"]}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"releaseSchema": "release-schema.json",
"releasePackageSchema": "release-package-schema.json",
"extensions": ["widgets/extension.json"]
}
+1
View File
@@ -0,0 +1 @@
{"name": {"en": "Widgets"}, "schemas": ["release-schema.json"], "dependencies": ["../orphan/extension.json"]}
+6
View File
@@ -0,0 +1,6 @@
{
"definitions": {
"Tender": {"properties": {"widgets": {"type": "array", "items": {"$ref": "#/definitions/Widget"}}}},
"Widget": {"type": "object", "properties": {"id": {"type": "string"}, "count": {"type": "integer"}}}
}
}
+441
View File
@@ -0,0 +1,441 @@
package generator
import (
"bytes"
"fmt"
"go/format"
"sort"
"strconv"
"strings"
"unicode"
)
type field struct {
GoName, JSONName, Type, Doc string
Required bool
}
type goStruct struct {
Name, Doc string
Fields []field
}
type emitter struct {
defs *Obj // release schema definitions
defNames map[string]string // schema definition name -> Go type name
used map[string]bool // Go type names taken
structs []*goStruct
usesTime bool
usesJSON bool
usesID bool
}
// Emit renders Go source for the merged schemas. declared is the list of
// extension URLs from the config, recorded as DeclaredExtensions.
func (gen *Generator) Emit(pkg, configName string, s *Schemas, exts []*Extension, declared []string, excludeFieldDoc bool) ([]byte, error) {
release, pkgSchema := s.Release, s.Package
g := &emitter{
defs: release.Obj("definitions"),
defNames: map[string]string{},
used: map[string]bool{"ID": true},
}
if g.defs == nil {
g.defs = newObj()
}
// Reserve names up front so inline types never take a definition's name.
g.used["Release"], g.used["ReleasePackage"] = true, true
names := append([]string(nil), g.defs.Keys...)
sort.Strings(names)
for _, n := range names {
gn := g.unique(goName(n))
g.defNames[n] = gn
}
gen.warnUnreachable(release, pkgSchema)
g.addStruct("ReleasePackage", pkgSchema)
g.addStruct("Release", release)
for _, n := range names {
g.addStruct(g.defNames[n], g.defs.Obj(n))
}
var b bytes.Buffer
fmt.Fprintf(&b, "// Code generated by ocdsgen from %s. DO NOT EDIT.\n\n", configName)
fmt.Fprintf(&b, "// Package %s contains Go types for OCDS releases with these extensions applied\n", pkg)
fmt.Fprintf(&b, "// (dependencies first, in the order their patches were merged):\n//\n")
for _, e := range exts {
tag := "dependency"
if e.Declared {
tag = "declared"
}
fmt.Fprintf(&b, "// - %s (%s)\n// %s\n", e.Name, tag, e.URL)
}
fmt.Fprintf(&b, "package %s\n\n", pkg)
var imports []string
if g.usesID {
imports = append(imports, `"fmt"`)
}
if g.usesJSON || g.usesID {
imports = append(imports, `"encoding/json"`)
}
if g.usesTime {
imports = append(imports, `"time"`)
}
if len(imports) > 0 {
fmt.Fprintf(&b, "import (\n%s\n)\n\n", strings.Join(imports, "\n"))
}
fmt.Fprintf(&b, "// DeclaredExtensions are the extension URLs to list in ReleasePackage.Extensions.\n")
fmt.Fprintf(&b, "var DeclaredExtensions = []string{\n")
for _, u := range declared {
fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(u))
}
fmt.Fprintf(&b, "}\n\n")
for _, s := range g.structs {
writeDoc(&b, "", s.Doc)
fmt.Fprintf(&b, "type %s struct {\n", s.Name)
for _, f := range s.Fields {
if !excludeFieldDoc {
writeDoc(&b, "\t", f.Doc)
}
tag := f.JSONName
if !f.Required {
tag += ",omitempty"
}
fmt.Fprintf(&b, "\t%s %s `json:%s`\n", f.GoName, f.Type, strconv.Quote(tag))
}
fmt.Fprintf(&b, "}\n\n")
}
if g.usesID {
b.WriteString(idType)
}
src, err := format.Source(b.Bytes())
if err != nil {
return b.Bytes(), fmt.Errorf("gofmt generated code: %w", err)
}
return src, nil
}
func (g *emitter) unique(name string) string {
n := name
for i := 2; g.used[n]; i++ {
n = fmt.Sprintf("%s%d", name, i)
}
g.used[n] = true
return n
}
// addStruct emits a struct for an object schema. Inline object properties
// become their own named types, emitted directly after their parent.
func (g *emitter) addStruct(name string, schema *Obj) {
s := &goStruct{Name: name, Doc: docFor(name, schema)}
g.structs = append(g.structs, s)
required := map[string]bool{}
if req, ok := schema.Get("required").([]any); ok {
for _, r := range req {
if rs, ok := r.(string); ok {
required[rs] = true
}
}
}
props := schema.Obj("properties")
if props == nil {
return
}
taken := map[string]bool{}
for _, key := range props.Keys {
ps, ok := props.M[key].(*Obj)
if !ok {
continue
}
gn := goName(key)
for i := 2; taken[gn]; i++ {
gn = fmt.Sprintf("%s%d", goName(key), i)
}
taken[gn] = true
typ := g.typeOf(name+goName(key), ps)
req := required[key]
if req && typ == "*time.Time" {
typ = "time.Time"
}
s.Fields = append(s.Fields, field{GoName: gn, JSONName: key, Type: typ, Doc: docFor("", ps), Required: req})
}
}
// typeOf maps a property schema to a Go type. inlineName is used if the
// property is an object with its own properties.
func (g *emitter) typeOf(inlineName string, s *Obj) string {
if ref := s.Str("$ref"); ref != "" {
switch {
case strings.HasPrefix(ref, "#/definitions/"):
if gn, ok := g.defNames[strings.TrimPrefix(ref, "#/definitions/")]; ok {
return "*" + gn
}
case strings.HasSuffix(ref, "release-schema.json"):
return "*Release"
}
g.usesJSON = true
return "json.RawMessage" // unresolvable reference
}
types := typeSet(s)
if len(types) == 0 {
if s.Obj("properties") != nil {
types = []string{"object"}
} else {
g.usesJSON = true
return "json.RawMessage"
}
}
if len(types) > 1 {
sort.Strings(types)
if strings.Join(types, ",") == "integer,string" {
g.usesID = true
return "ID"
}
g.usesJSON = true
return "json.RawMessage"
}
switch types[0] {
case "string":
if s.Str("format") == "date-time" {
g.usesTime = true
return "*time.Time"
}
return "string"
case "integer":
return "*int"
case "number":
return "*float64"
case "boolean":
return "*bool"
case "array":
items, ok := s.Get("items").(*Obj)
if !ok {
g.usesJSON = true
return "[]json.RawMessage"
}
return "[]" + strings.TrimPrefix(g.typeOf(inlineName, items), "*")
case "object":
if s.Obj("properties") != nil {
n := g.unique(inlineName)
g.addStruct(n, s)
return "*" + n
}
return "map[string]any"
}
g.usesJSON = true
return "json.RawMessage"
}
// warnUnreachable reports definitions that no field refers to. This usually
// means an extension patches a definition (e.g. Lot) that is introduced by
// another extension (e.g. Lots) which isn't in the config, so the patched
// fields would have nowhere to appear in the data.
func (gen *Generator) warnUnreachable(release, pkgSchema *Obj) {
defs := release.Obj("definitions")
if defs == nil {
return
}
reached := map[string]bool{}
var walk func(v any)
walk = func(v any) {
switch t := v.(type) {
case *Obj:
if ref := t.Str("$ref"); strings.HasPrefix(ref, "#/definitions/") {
name := strings.TrimPrefix(ref, "#/definitions/")
if !reached[name] {
reached[name] = true
if d := defs.Obj(name); d != nil {
walk(d)
}
}
}
for _, k := range t.Keys {
if k != "definitions" {
walk(t.M[k])
}
}
case []any:
for _, x := range t {
walk(x)
}
}
}
walk(release)
walk(pkgSchema)
for _, name := range defs.Keys {
if !reached[name] {
gen.warnf("definition %s is never referenced; an extension that embeds it may be missing from the config", name)
}
}
}
func typeSet(s *Obj) []string {
var out []string
switch t := s.Get("type").(type) {
case string:
if t != "null" {
out = append(out, t)
}
case []any:
for _, v := range t {
if vs, ok := v.(string); ok && vs != "null" {
out = append(out, vs)
}
}
}
return out
}
func docFor(name string, s *Obj) string {
var parts []string
desc := firstSentence(s.Str("description"))
if desc == "" {
desc = s.Str("title")
}
if desc != "" {
if name != "" {
desc = name + ": " + desc
}
parts = append(parts, desc)
}
if cl := s.Str("codelist"); cl != "" {
kind := "closed"
if open, _ := s.Get("openCodelist").(bool); open {
kind = "open"
}
parts = append(parts, fmt.Sprintf("Codelist: %s (%s).", cl, kind))
}
if dep, ok := s.Get("deprecated").(*Obj); ok {
msg := firstSentence(dep.Str("description"))
if v := dep.Str("deprecatedVersion"); v != "" {
msg = fmt.Sprintf("since OCDS %s. %s", v, msg)
}
parts = append(parts, "\nDeprecated: "+strings.TrimSpace(msg))
}
return strings.Join(parts, " ")
}
func firstSentence(s string) string {
s = strings.Join(strings.Fields(s), " ")
if i := strings.Index(s, ". "); i >= 0 {
s = s[:i+1]
}
if len(s) > 300 {
s = s[:297] + "..."
}
return s
}
func writeDoc(b *bytes.Buffer, indent, doc string) {
if doc == "" {
return
}
for _, para := range strings.Split(doc, "\n") {
line := ""
for _, w := range strings.Fields(para) {
if line != "" && len(line)+len(w) > 90 {
fmt.Fprintf(b, "%s// %s\n", indent, line)
line = ""
}
if line != "" {
line += " "
}
line += w
}
if line != "" {
fmt.Fprintf(b, "%s// %s\n", indent, line)
} else {
fmt.Fprintf(b, "%s//\n", indent)
}
}
}
var initialisms = map[string]bool{
"ID": true, "URI": true, "URL": true, "OCID": true, "JSON": true, "HTML": true,
"HTTP": true, "VAT": true, "EU": true, "UK": true, "SME": true, "CPV": true,
}
// goName converts a JSON key or definition name to an exported Go identifier,
// e.g. "awardID" -> "AwardID", "relatedLots" -> "RelatedLots", "uri" -> "URI".
func goName(s string) string {
var words []string
var cur []rune
flush := func() {
if len(cur) > 0 {
words = append(words, string(cur))
cur = nil
}
}
rs := []rune(s)
for i, r := range rs {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
flush()
continue
}
if unicode.IsUpper(r) && len(cur) > 0 {
prev := cur[len(cur)-1]
nextLower := i+1 < len(rs) && unicode.IsLower(rs[i+1])
if unicode.IsLower(prev) || unicode.IsDigit(prev) || (unicode.IsUpper(prev) && nextLower) {
flush()
}
}
cur = append(cur, r)
}
flush()
var out strings.Builder
for _, w := range words {
if up := strings.ToUpper(w); initialisms[up] {
out.WriteString(up)
continue
}
r := []rune(w)
out.WriteString(string(unicode.ToUpper(r[0])))
out.WriteString(string(r[1:]))
}
name := out.String()
if name == "" {
return "Field"
}
if unicode.IsDigit([]rune(name)[0]) {
name = "X" + name
}
return name
}
// idType is emitted into every generated package that needs it, so each
// package is self-contained and has no runtime dependency on this module.
const idType = `
// ID is used for schema fields typed as ["string", "integer"], such as Item.id
// and Award.id. It accepts a JSON string or number and always encodes as a string.
type ID string
func (id *ID) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
*id = ""
return nil
}
var s string
if err := json.Unmarshal(b, &s); err == nil {
*id = ID(s)
return nil
}
var n json.Number
if err := json.Unmarshal(b, &n); err != nil {
return fmt.Errorf("ID must be a string or integer, got %s", b)
}
*id = ID(n.String())
return nil
}
`
+351
View File
@@ -0,0 +1,351 @@
// Package generator builds Go types for OCDS releases from the core schema
// plus a set of extensions. It is used by the ocdsgen command and has no
// knowledge of the command line.
package generator
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
// Config is the contents of an extensions.json file.
type Config struct {
ReleaseSchema string `json:"releaseSchema"`
ReleasePackageSchema string `json:"releasePackageSchema"`
Extensions []string `json:"extensions"`
}
// LoadConfig reads and validates a config file. Entries that are relative
// local paths rather than URLs are resolved against the config file's directory.
func LoadConfig(p string) (*Config, error) {
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
var cfg Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
if cfg.ReleaseSchema == "" || cfg.ReleasePackageSchema == "" {
return nil, fmt.Errorf("%s: releaseSchema and releasePackageSchema are required", p)
}
dir := filepath.Dir(p)
cfg.ReleaseSchema = relativeTo(dir, cfg.ReleaseSchema)
cfg.ReleasePackageSchema = relativeTo(dir, cfg.ReleasePackageSchema)
for i, e := range cfg.Extensions {
cfg.Extensions[i] = relativeTo(dir, e)
}
return &cfg, nil
}
func relativeTo(dir, ref string) string {
if strings.Contains(ref, "://") || filepath.IsAbs(ref) {
return ref
}
return filepath.Join(dir, ref)
}
// Extension is one resolved extension.
type Extension struct {
URL string
Name string
Schemas []string
Dependencies []string
Declared bool // listed in the config rather than pulled in as a dependency
}
// Schemas holds the core schemas after extension patches are applied.
type Schemas struct {
Release *Obj
Package *Obj
}
// Generator holds shared state for a run: an HTTP client, a fetch cache and
// where warnings go. The zero value is not usable; call New.
type Generator struct {
client *http.Client
cache map[string][]byte
log io.Writer
warnings int
}
// New returns a Generator that writes notes and warnings to log.
func New(log io.Writer, timeout time.Duration) *Generator {
if log == nil {
log = io.Discard
}
return &Generator{client: &http.Client{Timeout: timeout}, cache: map[string][]byte{}, log: log}
}
// Warnings reports how many warnings have been emitted so far.
func (g *Generator) Warnings() int { return g.warnings }
func (g *Generator) warnf(format string, args ...any) {
g.warnings++
fmt.Fprintf(g.log, "warning: "+format+"\n", args...)
}
func (g *Generator) notef(format string, args ...any) {
fmt.Fprintf(g.log, "note: "+format+"\n", args...)
}
// Run resolves, merges and emits in one step.
func (g *Generator) Run(cfg *Config, pkg, configName string, excludeFieldDoc bool) ([]byte, error) {
exts, err := g.Resolve(cfg.Extensions)
if err != nil {
return nil, err
}
s, err := g.Merge(cfg, exts)
if err != nil {
return nil, err
}
return g.Emit(pkg, configName, s, exts, cfg.Extensions, excludeFieldDoc)
}
// Resolve fetches each extension's metadata and walks dependencies
// depth-first, so every extension comes after the extensions it depends on.
// Each URL appears once.
func (g *Generator) Resolve(urls []string) ([]*Extension, error) {
var order []*Extension
seen := map[string]bool{}
visiting := map[string]bool{}
declared := map[string]bool{}
for _, u := range urls {
declared[u] = true
}
var visit func(u string) error
visit = func(u string) error {
if seen[u] {
return nil
}
if visiting[u] {
return fmt.Errorf("dependency cycle at %s", u)
}
visiting[u] = true
var meta struct {
Name map[string]string `json:"name"`
Schemas []string `json:"schemas"`
Dependencies []string `json:"dependencies"`
}
body, err := g.fetch(u)
if err != nil {
return err
}
if err := json.Unmarshal(body, &meta); err != nil {
return fmt.Errorf("%s: %w", u, err)
}
e := &Extension{
URL: u,
Name: meta.Name["en"],
Schemas: meta.Schemas,
Dependencies: meta.Dependencies,
Declared: declared[u],
}
if e.Name == "" {
e.Name = u
}
for i, d := range meta.Dependencies {
// Dependencies are usually absolute URLs; relative ones are
// resolved against the extension that declares them.
if d, err = resolveRef(u, d); err != nil {
return err
}
e.Dependencies[i] = d
if err := visit(d); err != nil {
return fmt.Errorf("%s -> %w", e.Name, err)
}
}
visiting[u] = false
seen[u] = true
order = append(order, e)
return nil
}
for _, u := range urls {
if err := visit(u); err != nil {
return nil, err
}
}
g.warnDuplicateRepos(order)
return order, nil
}
// Merge fetches the core schemas and applies each extension's patches in
// order using JSON Merge Patch, as the OCDS tooling does.
func (g *Generator) Merge(cfg *Config, exts []*Extension) (*Schemas, error) {
release, err := g.fetchObj(cfg.ReleaseSchema)
if err != nil {
return nil, err
}
pkg, err := g.fetchObj(cfg.ReleasePackageSchema)
if err != nil {
return nil, err
}
for _, e := range exts {
for _, s := range e.Schemas {
var target **Obj
switch path.Base(s) {
case "release-schema.json":
target = &release
case "release-package-schema.json":
target = &pkg
default:
g.notef("%s: skipping %s (not generated)", e.Name, s)
continue
}
schemaURL, err := resolveRef(e.URL, s)
if err != nil {
return nil, err
}
patch, err := g.fetchObj(schemaURL)
if err != nil {
return nil, fmt.Errorf("%s: %w", e.Name, err)
}
g.warnTypeConflicts(e.Name, *target, patch)
*target = mergePatch(*target, patch).(*Obj)
}
}
return &Schemas{Release: release, Package: pkg}, nil
}
// warnDuplicateRepos flags the same extension pulled in at two different
// versions (e.g. .../master/extension.json and .../v1.1.5/extension.json).
func (g *Generator) warnDuplicateRepos(exts []*Extension) {
byRepo := map[string][]string{}
for _, e := range exts {
u, err := url.Parse(e.URL)
if err != nil {
continue
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) >= 2 {
repo := strings.ToLower(parts[0] + "/" + parts[1])
byRepo[repo] = append(byRepo[repo], e.URL)
}
}
repos := make([]string, 0, len(byRepo))
for r := range byRepo {
repos = append(repos, r)
}
sort.Strings(repos)
for _, r := range repos {
if len(byRepo[r]) > 1 {
g.warnf("%s is included more than once: %s", r, strings.Join(byRepo[r], ", "))
}
}
}
// warnTypeConflicts reports when a patch changes the JSON type of an existing
// property, which usually means two extensions disagree.
func (g *Generator) warnTypeConflicts(ext string, target, patch *Obj) {
check := func(where string, tProps, pProps *Obj) {
if tProps == nil || pProps == nil {
return
}
for _, k := range pProps.Keys {
pp, _ := pProps.M[k].(*Obj)
tp, _ := tProps.M[k].(*Obj)
if pp == nil || tp == nil {
continue
}
if a, b := typeKey(tp), typeKey(pp); a != "" && b != "" && a != b {
g.warnf("%s changes %s.%s from %s to %s", ext, where, k, a, b)
}
}
}
check("Release", target.Obj("properties"), patch.Obj("properties"))
pDefs, tDefs := patch.Obj("definitions"), target.Obj("definitions")
if pDefs == nil || tDefs == nil {
return
}
for _, name := range pDefs.Keys {
if pd, td := pDefs.Obj(name), tDefs.Obj(name); pd != nil && td != nil {
check(name, td.Obj("properties"), pd.Obj("properties"))
}
}
}
func typeKey(s *Obj) string {
if ref := s.Str("$ref"); ref != "" {
return "$ref:" + ref
}
ts := typeSet(s)
sort.Strings(ts)
return strings.Join(ts, "|")
}
func resolveRef(base, ref string) (string, error) {
if !strings.Contains(base, "://") {
// Local path: URL resolution would turn "a/b.json" + "../c.json"
// into "/c.json", so use file path rules instead.
if strings.Contains(ref, "://") || filepath.IsAbs(ref) {
return ref, nil
}
return filepath.Join(filepath.Dir(base), ref), nil
}
b, err := url.Parse(base)
if err != nil {
return "", err
}
r, err := url.Parse(ref)
if err != nil {
return "", err
}
return b.ResolveReference(r).String(), nil
}
func (g *Generator) fetch(u string) ([]byte, error) {
if b, ok := g.cache[u]; ok {
return b, nil
}
var body []byte
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
resp, err := g.client.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", u, resp.Status)
}
if body, err = io.ReadAll(resp.Body); err != nil {
return nil, err
}
} else {
// Local paths are allowed, e.g. for a vendored or private extension.
var err error
if body, err = os.ReadFile(strings.TrimPrefix(u, "file://")); err != nil {
return nil, err
}
}
g.cache[u] = body
return body, nil
}
func (g *Generator) fetchObj(u string) (*Obj, error) {
body, err := g.fetch(u)
if err != nil {
return nil, err
}
v, err := decodeOrdered(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("%s: %w", u, err)
}
o, ok := v.(*Obj)
if !ok {
return nil, fmt.Errorf("%s: not a JSON object", u)
}
return o, nil
}
+149
View File
@@ -0,0 +1,149 @@
package generator
import (
"bytes"
"encoding/json"
"fmt"
"io"
)
// Obj is a JSON object that remembers key order, so generated structs list
// fields in the same order as the schema rather than alphabetically.
type Obj struct {
Keys []string
M map[string]any
}
func newObj() *Obj { return &Obj{M: map[string]any{}} }
func (o *Obj) Get(k string) any { return o.M[k] }
func (o *Obj) Set(k string, v any) {
if _, ok := o.M[k]; !ok {
o.Keys = append(o.Keys, k)
}
o.M[k] = v
}
func (o *Obj) Delete(k string) {
if _, ok := o.M[k]; !ok {
return
}
delete(o.M, k)
for i, key := range o.Keys {
if key == k {
o.Keys = append(o.Keys[:i], o.Keys[i+1:]...)
break
}
}
}
func (o *Obj) Obj(k string) *Obj {
v, _ := o.M[k].(*Obj)
return v
}
func (o *Obj) Str(k string) string {
v, _ := o.M[k].(string)
return v
}
// decodeOrdered parses JSON, returning *Obj for objects, []any for arrays,
// and string / json.Number / bool / nil for scalars.
func decodeOrdered(r io.Reader) (any, error) {
dec := json.NewDecoder(r)
dec.UseNumber()
return parseValue(dec)
}
func parseValue(dec *json.Decoder) (any, error) {
tok, err := dec.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case json.Delim:
switch t {
case '{':
o := newObj()
for dec.More() {
kt, err := dec.Token()
if err != nil {
return nil, err
}
k, ok := kt.(string)
if !ok {
return nil, fmt.Errorf("expected object key, got %v", kt)
}
v, err := parseValue(dec)
if err != nil {
return nil, err
}
o.Set(k, v)
}
_, err := dec.Token() // '}'
return o, err
case '[':
arr := []any{}
for dec.More() {
v, err := parseValue(dec)
if err != nil {
return nil, err
}
arr = append(arr, v)
}
_, err := dec.Token() // ']'
return arr, err
}
return nil, fmt.Errorf("unexpected delimiter %v", t)
default:
return t, nil
}
}
// mergePatch applies an RFC 7386 JSON Merge Patch, which is how OCDS
// extensions are applied to the core schema. A null in the patch deletes
// the key; objects merge recursively; anything else replaces.
func mergePatch(target, patch any) any {
p, ok := patch.(*Obj)
if !ok {
return patch
}
t, ok := target.(*Obj)
if !ok {
t = newObj()
}
for _, k := range p.Keys {
v := p.M[k]
if v == nil {
t.Delete(k)
continue
}
t.Set(k, mergePatch(t.M[k], v))
}
return t
}
// MarshalJSON writes the object with its keys in their original order.
func (o *Obj) MarshalJSON() ([]byte, error) {
var b bytes.Buffer
b.WriteByte('{')
for i, k := range o.Keys {
if i > 0 {
b.WriteByte(',')
}
kb, err := json.Marshal(k)
if err != nil {
return nil, err
}
vb, err := json.Marshal(o.M[k])
if err != nil {
return nil, err
}
b.Write(kb)
b.WriteByte(':')
b.Write(vb)
}
b.WriteByte('}')
return b.Bytes(), nil
}