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 } `