infer: Add Schemer interface for self-described types#78
Conversation
Types whose MarshalJSON emits a primitive (decimal wrappers, UUIDs,
custom time formats, money types) currently render as {type: "object"}
because the generator reflects on the Go struct definition rather than
the marshalled form. The only escape today is ForOptions.TypeSchemas,
which puts the burden on every consumer.
Add a Schemer interface that lets types self-describe:
type Schemer interface { JSONSchema() *Schema }
Types implementing it (with either receiver style) get the schema they
declare. Returning nil falls back to default reflection. TypeSchemas
still wins so callers can override third-party types they don't own.
See google#77 for the motivating discussion.
|
@jwetzell @rafaeljusto @jba @baptmont would a review be possible? 🙏 |
|
Hey @echarrod, I'm not an official repository maintainer, so I'll defer the final review to others. As you mentioned in the PR description, this use case can already be addressed through the jsonschema-go/jsonschema/infer.go Lines 34 to 41 in 794ce5e Since this doesn't add new capabilities, the question is mostly whether the maintainers want to support this convenience in the library itself (multiple ways of doing the same thing). |
|
I think this is reasonable. It's not really two ways of doing the same thing. TypeSchemas is convenient for the "end-user," the one calling I approve. However, I'm no longer the primary maintainer. I'll pass this along. |
Implementation sketch for #77.
What
Adds a small interface that lets types self-describe their JSON Schema:
If a type (or
*T) implementsSchemer,For/ForTypeuse the returned schema instead of inferring one from the Go struct. Returningnilfalls back to default inference.ForOptions.TypeSchemasstill takes precedence, so callers can override a third-party type they don't control.Why
Library types whose
MarshalJSONemits a JSON primitive (string / number / boolean) currently render as{type: "object"}because the generator inspects the Go struct definition rather than the marshalled form. Examples in the wild:decimal.Decimalfromgithub.com/luno/luno-go/decimal, UUID wrappers, custom time formats, money types.The library already special-cases
time.Time,slog.Level,big.Int,big.Rat,big.FloatviainitialSchemaMap.Schemergeneralises that pattern so type authors can opt in once and downstream consumers get the right schema for free.The concrete failure that motivated this: an MCP server using
mcp-go'sWithOutputSchema[T](which callsjsonschema.For[T]) had every order book entry rejected by strict clients withdata/asks/0/price must be object, becausedecimal.Decimalreflected as object. See luno/luno-mcp#128 for the downstream workaround usingForOptions.TypeSchemas.Behaviour
TimplementingSchemerwith a value receiver → uses the returned schema.*TimplementingSchemerwith a pointer receiver → same.JSONSchema()returningnil→ falls back to default reflection (no panic).*Tfield whereTimplementsSchemer→ schema is null-wrapped (["null", "string"]etc.), matching how the existinginitialSchemaMaplookup behaves for pointers.[]TwhereTimplementsSchemer→ items schema comes fromSchemer.ForOptions.TypeSchemas[T]set → wins overSchemer.Tests
TestForWithSchemercovers value receiver, pointer receiver, pointer-to-Schemer null-wrapping, nil-return fallback, and slice elements.TestForWithSchemerOverriddenByTypeSchemascovers the precedence rule.Existing test suite unchanged and green.
Open questions
JSONSchema()take a context? I chose the zero-argument signature for symmetry withMarshalJSONand because instance state is meaningless here. Open to passing*ForOptionsif there's a need.reflect.Type? Each schema generation currently callsJSONSchema()once per occurrence of a Schemer type in the tree. Cheap, but cacheable if profiling shows a hot path.JSONSchemamatchesMarshalJSON; alternatives areSchema,JSONSchemaFor,OpenAPISchemaType-style. Easy to change.Happy to iterate.