Files
go-ocds/internal/generator/generator.go
T

352 lines
9.1 KiB
Go

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