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

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