From 2d7eb502e3cfa15fa112791b8f7364505bfdf76f Mon Sep 17 00:00:00 2001 From: AZERDSQ Date: Wed, 8 Jul 2026 07:07:00 +0200 Subject: [PATCH] feat: add AddPackageNamespaces option to disambiguate same-name types across packages --- README.md | 18 +++++++++++- internal/nsfixture/httpconf/httpconf.go | 9 ++++++ internal/nsfixture/tcpconf/tcpconf.go | 9 ++++++ reflect.go | 16 ++++++++++- reflect_test.go | 37 +++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 internal/nsfixture/httpconf/httpconf.go create mode 100644 internal/nsfixture/tcpconf/tcpconf.go diff --git a/README.md b/README.md index bc29120..b0461f9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This repository is a fork of the original [jsonschema](https://github.com/alecth - The original was stuck on the draft-04 version of JSON Schema, we've now moved to the latest JSON Schema Draft 2020-12. - Schema IDs are added automatically from the current Go package's URL in order to be unique, and can be disabled with the `Anonymous` option. -- Support for the `FullyQualifyTypeName` option has been removed. If you have conflicts, you should use multiple schema files with different IDs, set the `DoNotReference` option to true to hide definitions completely, or add your own naming strategy using the `Namer` property. +- Support for the `FullyQualifyTypeName` option has been removed. If you have conflicts, you should use multiple schema files with different IDs, set the `DoNotReference` option to true to hide definitions completely, add your own naming strategy using the `Namer` property, or use the `AddPackageNamespaces` option described in [Type Naming and Conflicts](#type-naming-and-conflicts). - Support for `yaml` tags and related options has been dropped for the sake of simplification. There were a [few inconsistencies](https://github.com/invopop/jsonschema/pull/21) around this that have now been fixed. ## Versions @@ -246,6 +246,22 @@ Expect the results to be similar to: } ``` +### Type Naming and Conflicts + +By default, definitions are named after the Go type's name (`t.Name()`). If two different packages define a type with the same name (e.g. two unrelated `Config` structs) and both end up referenced in the same schema, they will collide on the same `$defs` entry. + +You have a few ways to resolve this: + +- Set `Reflector.AddPackageNamespaces` to `true` to prefix every generated type name with its package name, e.g. `httpconf.Config` and `tcpconf.Config` instead of two conflicting `Config` entries. +- Provide your own `Reflector.Namer` function for full control over naming, including selectively qualifying only the types that actually conflict. +- Set `Reflector.DoNotReference` to `true` to avoid `$defs`/`$ref` altogether and inline every type in place. + +```go +r := new(Reflector) +r.AddPackageNamespaces = true +schema := r.Reflect(&AllConfig{}) +``` + ### Custom Key Naming In some situations, the keys actually used to write files are different from Go structs'. diff --git a/internal/nsfixture/httpconf/httpconf.go b/internal/nsfixture/httpconf/httpconf.go new file mode 100644 index 0000000..eb2cce1 --- /dev/null +++ b/internal/nsfixture/httpconf/httpconf.go @@ -0,0 +1,9 @@ +// Package httpconf provides a fixture type used to test AddPackageNamespaces: +// a Config struct that collides in name with tcpconf.Config. +package httpconf + +// Config holds HTTP-specific configuration. +type Config struct { + URL string `json:"url" jsonschema:"required,format=uri"` + Method string `json:"method" jsonschema:"required"` +} diff --git a/internal/nsfixture/tcpconf/tcpconf.go b/internal/nsfixture/tcpconf/tcpconf.go new file mode 100644 index 0000000..800d771 --- /dev/null +++ b/internal/nsfixture/tcpconf/tcpconf.go @@ -0,0 +1,9 @@ +// Package tcpconf provides a fixture type used to test AddPackageNamespaces: +// a Config struct that collides in name with httpconf.Config. +package tcpconf + +// Config holds TCP-specific configuration. +type Config struct { + Host string `json:"host" jsonschema:"required,format=hostname"` + Port int `json:"port" jsonschema:"required,minimum=1,maximum=65535"` +} diff --git a/reflect.go b/reflect.go index 143dfa8..c71124c 100644 --- a/reflect.go +++ b/reflect.go @@ -11,6 +11,7 @@ import ( "encoding/json" "net" "net/url" + "path" "reflect" "strconv" "strings" @@ -135,6 +136,13 @@ type Reflector struct { // provided by the reflect package. Namer func(reflect.Type) string + // AddPackageNamespaces will prefix the generated name of a type with its package + // name (the last element of its import path) when set to true, e.g. "http.Config" + // instead of "Config". This helps disambiguate identically-named types declared in + // different packages, which would otherwise collide on the same $ref/$defs entry. + // It has no effect on types for which Namer returns a non-empty name. + AddPackageNamespaces bool + // KeyNamer allows customizing of key names. // The default is to use the key's name as is, or the json tag if present. // If a json tag is present, KeyNamer will receive the tag's name as an argument, not the original key name. @@ -1145,7 +1153,13 @@ func (r *Reflector) typeName(t reflect.Type) string { return name } } - return t.Name() + name := t.Name() + if r.AddPackageNamespaces { + if pkg := path.Base(t.PkgPath()); pkg != "" && pkg != "." { + name = pkg + "." + name + } + } + return name } // Split on commas that are not preceded by `\`. diff --git a/reflect_test.go b/reflect_test.go index 004738f..2230ab7 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -15,6 +15,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/invopop/jsonschema/internal/nsfixture/httpconf" + "github.com/invopop/jsonschema/internal/nsfixture/tcpconf" ) var updateFixtures = flag.Bool("update", false, "set to update fixtures") @@ -366,6 +369,40 @@ func TestReflectFromType(t *testing.T) { assert.Empty(t, s.ID) } +func TestAddPackageNamespacesDisambiguatesSameNameStructs(t *testing.T) { + type AllConfig struct { + TCP tcpconf.Config `json:"tcp"` + HTTP httpconf.Config `json:"http"` + } + + r := &Reflector{AddPackageNamespaces: true} + s := r.Reflect(&AllConfig{}) + + tcpRef, ok := s.Definitions["jsonschema.AllConfig"].Properties.Get("tcp") + require.True(t, ok) + httpRef, ok := s.Definitions["jsonschema.AllConfig"].Properties.Get("http") + require.True(t, ok) + + assert.Equal(t, "#/$defs/tcpconf.Config", tcpRef.Ref) + assert.Equal(t, "#/$defs/httpconf.Config", httpRef.Ref) + assert.NotEqual(t, tcpRef.Ref, httpRef.Ref) + + _, hasTCPConfig := s.Definitions["tcpconf.Config"] + _, hasHTTPConfig := s.Definitions["httpconf.Config"] + assert.True(t, hasTCPConfig) + assert.True(t, hasHTTPConfig) + + // Without AddPackageNamespaces, both structs collide on the same "Config" $ref. + rDefault := new(Reflector) + sDefault := rDefault.Reflect(&AllConfig{}) + tcpRefDefault, ok := sDefault.Definitions["AllConfig"].Properties.Get("tcp") + require.True(t, ok) + httpRefDefault, ok := sDefault.Definitions["AllConfig"].Properties.Get("http") + require.True(t, ok) + assert.Equal(t, "#/$defs/Config", tcpRefDefault.Ref) + assert.Equal(t, "#/$defs/Config", httpRefDefault.Ref) +} + func TestSchemaGeneration(t *testing.T) { tests := []struct { typ any