From 752211e796ee4a91616a2d034944939b42e64ad0 Mon Sep 17 00:00:00 2001 From: Frenchie Date: Wed, 27 Aug 2025 16:21:53 +1000 Subject: [PATCH] feat(nullable): add shared nullability detector for OpenAPI 3.1 Introduce pkg/codegen/nullable.go with IsSchemaNullable(specVersion, openapi3.SchemaRef) - Support 3.1 JSON Schema null patterns: type: ["T","null"], oneOf/anyOf with null-only arm - Fall back to 3.0 schema.Nullable - add tests pkg/codegen/nullable_test.go - No integration yet; generation behavior unchanged until wired in --- pkg/codegen/nullable.go | 128 +++++++++++++++ pkg/codegen/nullable_test.go | 294 +++++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 pkg/codegen/nullable.go create mode 100644 pkg/codegen/nullable_test.go diff --git a/pkg/codegen/nullable.go b/pkg/codegen/nullable.go new file mode 100644 index 0000000000..56d734ad8e --- /dev/null +++ b/pkg/codegen/nullable.go @@ -0,0 +1,128 @@ +package codegen + +import ( + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// IsSchemaNullable determines whether a schema permits JSON null. +// +// - For OpenAPI 3.1 (JSON Schema 2020-12), a schema is nullable if, considering +// base constraints (type/enum/not) and composition (oneOf/anyOf/allOf), the +// overall schema would accept the JSON null value. Empty schemas `{}` are not +// considered nullable here by design to avoid over-detection in generators. +// - For OpenAPI 3.0, falls back to `schema.Value.Nullable`. +func IsSchemaNullable(specVersion string, sref *openapi3.SchemaRef) bool { + if sref == nil || sref.Value == nil { + return false + } + + if strings.HasPrefix(specVersion, "3.1") { + return permitsNull31(sref) + } + return sref.Value.Nullable +} + +// permitsNull31 returns true if the schema (draft 2020-12 semantics) permits JSON null, +// accounting for base constraints (type/enum/not) and composed constraints (oneOf/anyOf/allOf). +// NOTE: Empty schemas are intentionally treated as non-nullable for generator purposes. +func permitsNull31(schemaRef *openapi3.SchemaRef) bool { + if schemaRef == nil || schemaRef.Value == nil { + return false + } + schema := schemaRef.Value + + // Base constraints that immediately rule out null + if baseDisallowsNull(schema) { + return false + } + // "not" forbids null if its subschema would accept null + if notForbidsNull(schema.Not) { + return false + } + + // Composition constraints - AllOf + if len(schema.AllOf) > 0 { + for _, sub := range schema.AllOf { + if !permitsNull31(sub) { + return false + } + } + return true + } + // Composition constraints - OneOf + if len(schema.OneOf) > 0 { + matches := 0 + for _, sub := range schema.OneOf { + if permitsNull31(sub) { + matches++ + } + } + return matches == 1 + } + // Composition constraints - AnyOf + if len(schema.AnyOf) > 0 { + for _, sub := range schema.AnyOf { + if permitsNull31(sub) { + return true + } + } + return false + } + + // No composition: decide from type/enum + if typeIncludesNull(schema.Type) { + return true + } + if enumIncludesNull(schema.Enum) { + return true + } + + // Treat empty/unconstrained schemas as non-nullable for codegen + return false +} + +func baseDisallowsNull(s *openapi3.Schema) bool { + // If a base type is present and does NOT include null, null cannot be valid overall + if s.Type != nil && !typeIncludesNull(s.Type) { + return true + } + // If enum exists and does NOT contain null, then null is disallowed + if len(s.Enum) > 0 && !enumIncludesNull(s.Enum) { + return true + } + return false +} + +func typeIncludesNull(t *openapi3.Types) bool { + if t == nil { + return false + } + if t.Is(openapi3.TypeNull) { + return true + } + for _, typ := range t.Slice() { + if typ == openapi3.TypeNull { + return true + } + } + return false +} + +func enumIncludesNull(values []any) bool { + for _, v := range values { + if v == nil { + return true + } + } + return false +} + +func notForbidsNull(notRef *openapi3.SchemaRef) bool { + if notRef == nil || notRef.Value == nil { + return false + } + // If the negated schema would accept null, then the parent forbids null + return permitsNull31(notRef) +} diff --git a/pkg/codegen/nullable_test.go b/pkg/codegen/nullable_test.go new file mode 100644 index 0000000000..9a7701fa97 --- /dev/null +++ b/pkg/codegen/nullable_test.go @@ -0,0 +1,294 @@ +package codegen + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func schemaRefFromDoc(t *testing.T, docYAML string, schemaName string) (*openapi3.SchemaRef, string) { + t.Helper() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(docYAML)) + require.NoError(t, err, "failed to load OpenAPI document") + ref, ok := doc.Components.Schemas[schemaName] + require.Truef(t, ok, "schema %q not found in components", schemaName) + return ref, doc.OpenAPI +} + +func TestIsSchemaNullable_31_TypeOnlyNull(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: 'null' +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_TypeOnlyString(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: 'string' +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_TypeUnionIncludesNull(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: [string, 'null'] +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_NonNullable(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: [string, integer] +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_OneOfWithNullOnly(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + oneOf: + - { type: string } + - { type: 'null' } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_OneOf_AllNullArms(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + oneOf: + - { type: 'null' } + - { type: 'null' } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + // JSON Schema oneOf requires exactly one match; null matches both arms → not nullable via oneOf + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_OneOf_MixedMultipleNullAllowingArms(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + oneOf: + - { type: ['string','null'] } + - { type: 'null' } + - { type: integer } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + // Two subschemas explicitly allow null -> oneOf(null) must fail (violates exactly-one) + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_AnyOfWithNullOnly(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + anyOf: + - { type: integer } + - { type: 'null' } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_AnyOf_AllNullArms(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + anyOf: + - { type: 'null' } + - { type: 'null' } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_AllOf_OneNullArm(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + allOf: + - { type: 'null' } + - { type: string } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_AllOf_AllNullArms(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + allOf: + - { type: 'null' } + - { type: 'null' } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} +func TestIsSchemaNullable_31_BaseTypeBlocksNull_InAnyOf(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: string + anyOf: + - { type: 'null' } + - { minLength: 1 } +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_EnumAllowsNull_NoType(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + enum: [null, "x"] +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_EnumAllowsNull_WithTypeString_Disallowed(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: string + enum: [null, "x"] +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_NotBlocksNull(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: ['string','null'] + not: + type: 'null' +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_31_EmptySchema_NotNullableByPolicy(t *testing.T) { + doc := ` +openapi: 3.1.0 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: {} +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_30_FallbackTrue(t *testing.T) { + doc := ` +openapi: 3.0.3 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: string + nullable: true +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.True(t, IsSchemaNullable(ver, ref)) +} + +func TestIsSchemaNullable_30_FallbackFalse(t *testing.T) { + doc := ` +openapi: 3.0.3 +info: { title: t, version: v } +paths: {} +components: + schemas: + S: + type: string +` + ref, ver := schemaRefFromDoc(t, doc, "S") + assert.False(t, IsSchemaNullable(ver, ref)) +}