From 67bdc9ea0d272e6128757096880f3999518e936f Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Mon, 27 Jul 2026 22:31:19 +0200 Subject: [PATCH] fix: allow metadata-only collection updates CollectionUpdateSchema.Fields was generated as `json:"fields"`, so an update changing only `metadata` still serialized `"fields":null`. Typesense rejects that with a 400, as it does `"fields":[]`, which left no value of Fields that could express a metadata-only update. The server applies and persists the metadata change before it validates `fields`, so callers got back an error describing a write that had actually happened. The upstream spec no longer marks `fields` as required (https://github.com/typesense/typesense-api-spec/pull/107), making the property optional. Generating it with x-go-type-skip-optional-pointer keeps it as []Field rather than *[]Field, so it gains `omitempty` without breaking callers that construct a CollectionUpdateSchema. Only the CollectionUpdateSchema portion of the vendored spec is updated here. Re-vendoring wholesale would revert the local `hits` patch from #216 and rename EnableOverrides/OverrideTags, which belongs in its own change. --- typesense/api/generator/generator.yml | 12 ++++---- typesense/api/generator/main.go | 19 +++++++++++++ typesense/api/generator/openapi.yml | 3 +- typesense/api/types_gen.go | 2 +- typesense/collection_test.go | 41 +++++++++++++++++++++++++++ typesense/test/collection_test.go | 30 ++++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/typesense/api/generator/generator.yml b/typesense/api/generator/generator.yml index 47c34d65..d0908aa6 100644 --- a/typesense/api/generator/generator.yml +++ b/typesense/api/generator/generator.yml @@ -380,6 +380,7 @@ components: - fields type: object CollectionUpdateSchema: + minProperties: 1 properties: fields: description: A list of fields for querying, filtering and faceting @@ -396,6 +397,7 @@ components: items: $ref: '#/components/schemas/Field' type: array + x-go-type-skip-optional-pointer: true metadata: description: | Optional details about the collection, e.g., when it was created, who created it etc. @@ -406,8 +408,6 @@ components: example: synonym_set_1 type: string type: array - required: - - fields type: object ConversationModelCreateSchema: allOf: @@ -1075,14 +1075,14 @@ components: properties: conversation: $ref: '#/components/schemas/SearchResultConversation' - results: - items: - $ref: '#/components/schemas/MultiSearchResultItem' - type: array hits: items: $ref: '#/components/schemas/SearchResultHit' type: array + results: + items: + $ref: '#/components/schemas/MultiSearchResultItem' + type: array required: - results type: object diff --git a/typesense/api/generator/main.go b/typesense/api/generator/main.go index 0e3cc38f..3a66c039 100644 --- a/typesense/api/generator/main.go +++ b/typesense/api/generator/main.go @@ -90,6 +90,9 @@ func processOpenAPISpec(m *yml) { // Remove additionalProperties from SearchResultHit -> document log.Println("Removing additionalProperties from SearchResultHit") searchResultHit(m) + // Keep CollectionUpdateSchema -> fields as a plain slice + log.Println("Skipping the optional pointer for CollectionUpdateSchema fields") + collectionUpdateFields(m) // Extract anonymous structs to named types log.Println("Extracting anonymous structs to named types") extractAnonymousStructs(m) @@ -147,6 +150,22 @@ func searchResultHit(m *yml) { delete(document, "additionalProperties") } +// collectionUpdateFields keeps CollectionUpdateSchema -> fields generated as []Field +// rather than *[]Field. +// +// The collection update endpoint accepts an update that changes only `metadata`, so +// the spec does not list `fields` as required. oapi-codegen renders an optional array +// as a pointer, which would break every caller constructing a CollectionUpdateSchema. +// x-go-type-skip-optional-pointer leaves the slice unwrapped, and `omitempty` is still +// applied because the property is optional -- so a nil or empty Fields sends no +// `fields` key at all. That is what the endpoint needs: it rejects both `"fields":null` +// and `"fields":[]` with a 400. +func collectionUpdateFields(m *yml) { + properties := (*m)["components"].(yml)["schemas"].(yml)["CollectionUpdateSchema"].(yml)["properties"].(yml) + fields := properties["fields"].(yml) + fields["x-go-type-skip-optional-pointer"] = true +} + func unwrapDeleteDocument(m *yml) { parameters := (*m)["paths"].(yml)["/collections/{collectionName}/documents"].(yml)["delete"].(yml)["parameters"].([]interface{}) deleteParameters := parameters[1].(yml)["schema"].(yml)["properties"].(yml) diff --git a/typesense/api/generator/openapi.yml b/typesense/api/generator/openapi.yml index b545158a..658ea640 100644 --- a/typesense/api/generator/openapi.yml +++ b/typesense/api/generator/openapi.yml @@ -2479,9 +2479,8 @@ components: description: > Optional details about the collection, e.g., when it was created, who created it etc. CollectionUpdateSchema: - required: - - fields type: object + minProperties: 1 properties: fields: type: array diff --git a/typesense/api/types_gen.go b/typesense/api/types_gen.go index ede4fbe6..47268da5 100644 --- a/typesense/api/types_gen.go +++ b/typesense/api/types_gen.go @@ -295,7 +295,7 @@ type CollectionSchema struct { // CollectionUpdateSchema defines model for CollectionUpdateSchema. type CollectionUpdateSchema struct { // Fields A list of fields for querying, filtering and faceting - Fields []Field `json:"fields"` + Fields []Field `json:"fields,omitempty"` // Metadata Optional details about the collection, e.g., when it was created, who created it etc. Metadata *map[string]interface{} `json:"metadata,omitempty"` diff --git a/typesense/collection_test.go b/typesense/collection_test.go index 388f6285..ccb9d022 100644 --- a/typesense/collection_test.go +++ b/typesense/collection_test.go @@ -2,6 +2,7 @@ package typesense import ( "context" + "encoding/json" "errors" "net/http" "testing" @@ -178,6 +179,46 @@ func TestCollectionUpdate(t *testing.T) { assert.Equal(t, expectedResult, result) } +// A collection update that changes only the metadata must not send a `fields` key at +// all: Typesense rejects both `"fields":null` and `"fields":[]` with a 400. This relies +// on the `omitempty` tag generated for CollectionUpdateSchema.Fields. +func TestCollectionUpdateSchemaOmitsEmptyFields(t *testing.T) { + tests := []struct { + name string + fields []api.Field + wantFields bool + }{ + { + name: "nil fields", + fields: nil, + }, + { + name: "empty fields", + fields: []api.Field{}, + }, + { + name: "populated fields", + fields: []api.Field{{Name: "country", Drop: pointer.True()}}, + wantFields: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, err := json.Marshal(&api.CollectionUpdateSchema{ + Fields: tt.fields, + Metadata: &map[string]interface{}{"revision": "2"}, + }) + assert.NoError(t, err) + + payload := map[string]json.RawMessage{} + assert.NoError(t, json.Unmarshal(body, &payload)) + + _, hasFields := payload["fields"] + assert.Equal(t, tt.wantFields, hasFields, "body: %s", body) + }) + } +} + func TestCollectionUpdateOnApiClientErrorReturnsError(t *testing.T) { updateSchema := updateExistingSchema() diff --git a/typesense/test/collection_test.go b/typesense/test/collection_test.go index 74e77dad..ef56f2af 100644 --- a/typesense/test/collection_test.go +++ b/typesense/test/collection_test.go @@ -58,3 +58,33 @@ func TestCollectionUpdate(t *testing.T) { require.Equal(t, pointer.True(), result.Fields[0].Drop) require.Equal(t, "2", (*result.Metadata)["revision"].(string)) } + +// Updating only the metadata has to leave `fields` out of the request entirely, since +// Typesense rejects both `"fields":null` and `"fields":[]` with a 400. +func TestCollectionUpdateMetadataOnly(t *testing.T) { + for _, tt := range []struct { + name string + fields []api.Field + }{ + {name: "nil fields", fields: nil}, + {name: "empty fields", fields: []api.Field{}}, + } { + t.Run(tt.name, func(t *testing.T) { + collectionName := createNewCollection(t, "companies") + + result, err := typesenseClient.Collection(collectionName).Update(context.Background(), + &api.CollectionUpdateSchema{ + Fields: tt.fields, + Metadata: &map[string]interface{}{"revision": "2"}, + }) + require.NoError(t, err) + require.Equal(t, "2", (*result.Metadata)["revision"].(string)) + + // Confirm the change reached the collection and left the schema alone. + updated, err := typesenseClient.Collection(collectionName).Retrieve(context.Background()) + require.NoError(t, err) + require.Equal(t, "2", (*updated.Metadata)["revision"].(string)) + require.Len(t, updated.Fields, 3) + }) + } +}