442 lines
10 KiB
Go
442 lines
10 KiB
Go
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
|
|
}
|
|
`
|