Complete package for generating ocds structs (with extensions) in golang
This commit is contained in:
1 parent
da726550d4
commit
35974fa070
47 files changed
+9861
-299
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"releaseSchema": "release-schema.json",
|
||||
"releasePackageSchema": "release-package-schema.json",
|
||||
"extensions": []
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"name": {"en": "Orphan"}, "schemas": ["release-schema.json"]}
|
||||
@@ -0,0 +1 @@
|
||||
{"definitions": {"Gadget": {"type": "object", "properties": {"name": {"type": "string"}}}}}
|
||||
@@ -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
@@ -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"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"releaseSchema": "release-schema.json",
|
||||
"releasePackageSchema": "release-package-schema.json",
|
||||
"extensions": ["widgets/extension.json"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name": {"en": "Widgets"}, "schemas": ["release-schema.json"], "dependencies": ["../orphan/extension.json"]}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"definitions": {
|
||||
"Tender": {"properties": {"widgets": {"type": "array", "items": {"$ref": "#/definitions/Widget"}}}},
|
||||
"Widget": {"type": "object", "properties": {"id": {"type": "string"}, "count": {"type": "integer"}}}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user