Skip to content

Commit 4e7fc82

Browse files
ryanyuansean-
andauthored
fix(osgen)!: unshadow generic-erased response fields and reduce the type surface (#1034)
`_id` was unreachable from a typed search response. SearchResultHits embedded SearchHitsMetadata, which declares json:"hits", and redeclared hits at depth 0. encoding/json resolves a duplicate tag at differing depths in favor of the shallower field, so the embedded slice was never populated: the winner held only _source, against the shadowed type's full SearchHit envelope. resp.Hits.Hits[0].ID returned nothing, and neither did _index, _score, _routing, the optimistic-concurrency pair _seq_no/_primary_term, nor the sort cursor for search_after pagination. Re-parsing SearchResp.RawBody() was the only escape. The cause is the spec instantiating generic types (HitsMetadata[TDocument], GetResult[TDocument]) as an allOf whose second member narrows one property to the type argument. osgen has no generics, flattens TDocument to json.RawMessage, and emitted the narrowing as a sibling field that shadowed the base. A narrowing that substitutes nothing a Go type can express is now dropped when the allOf base already declares the property, and a schema left contributing nothing beyond its base resolves to the base instead of registering a wrapper. One that names a concrete schema is left alone, which preserves the intended cases: bucket aggregations narrowing buckets from the erased TBucket, and SearchResultJSONValue.suggest, whose union adds a completion branch the base lacks. Response bodies are exempt, since an operation's Resp inlines the fields of whatever its ref resolves to. Search hits were the loudest case, not the only one. GetResult[TDocument] is the same erasure, so _source was also shadowed on Doc.Get, MGet, and Explain responses, where GetResp redeclared _source over the copy it already inherited from GetResultBase. Duplicate tags across an embed boundary drop from 30 to 22 over the generated surface, and the 22 that remain are the deliberate ones: 21 bucket aggregations plus SearchResultJSONValue.suggest. The eight cleared are SearchResultHits.hits, SearchHitsMetadataJSONValue.hits, SearchResultJSONValue.hits, SearchHitsMetadataHitsItem._source, GetResp._source, GetResult._source, ExplainRespBodyGet._source, and one bucket narrowing that collapsed with its wrapper. Fixing that exposed how much of the generated surface existed only as an artifact of the same erasure, so this also collapses it: opensearchapi goes from 2,379 exported generated types to 2,139. 34 of 66 identity wrappers are gone, 148 per-union branch error types become one UnionBranchError, a $ref'd union is named after its own schema and emitted once rather than once per referencing field (212 unions to 122, with 91 distinct shapes surviving where 87 did before), and type names over 60 characters drop from 74 to 16. osgen also now honors the OpenAPI discriminator, which it had ignored entirely. Eight schemas declare one and every branch of all eight resolves distinctly, so 146 subtypes the payload names outright were being decoded by a JSON token-class heuristic. Six unions gain a Type(), discriminant constants, and an UnmarshalJSON that reads one property and decodes exactly that branch; a value naming no branch is an error rather than a silent mis-decode. Accessors on a request-selected union stop returning a zero value with a nil error when the payload cannot be that branch, which a decode-error check cannot detect because encoding/json ignores unknown keys. Two generation guards keep the class of bug from returning. The first pins duplicate JSON tags across an embed boundary, which is the exact shape that caused this: go vet's structtag analyzer only compares tags within a single struct, and golangci-lint excludes generated files, so nothing caught it. Its allowlist is seeded with the 22 sites that exist today, all deliberate. The second reads version extensions written beside a $ref, which kin-openapi keeps on the reference rather than the resolved schema; 141 annotations were being dropped, and the same values feed the version filter, so those fields could not be excluded by -min-version or -max-version. The generic-substitution traversals are now bounded by $ref cycle detection rather than a seven-level depth cap that was never a termination guarantee. BREAKING CHANGE: every union branch accessor returns (T, error) instead of T, so v := u.Branch() becomes v, err := u.Branch(). BREAKING CHANGE: the 148 per-union branch error types are replaced by one UnionBranchError carrying Union, Branch, Got, and an Err reachable through errors.As. BREAKING CHANGE: 135 generated union types are removed and 45 added, and branch accessors drop the group prefix the union name already carries. See opensearchapi/UPGRADING_V4_TO_V5.md for the rename table. BREAKING CHANGE: apirev.Field.IsPointer is now a method and the surface JSON drops the isPointer key. This lives under cmd/osapilint/internal/, so it is not importable by consumers. Co-authored-by: Sean Chittenden <sean.chittenden@crowdstrike.com> Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com> Signed-off-by: Ryan Yuan <ryan.yuan@crowdstrike.com>
1 parent 2f11b1b commit 4e7fc82

414 files changed

Lines changed: 40646 additions & 64369 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.codecov.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# https://docs.codecov.com/docs/pull-request-comments
22
comment:
33
layout: "diff, flags, files"
4-
behavior: default
4+
behavior: new
55
require_changes: false # if true: only post the comment if coverage changes
66
require_base: false # [true :: must have a base report to post]
77
require_head: true # [true :: must have a head report to post]

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Large diffs are not rendered by default.

DEVELOPER_GUIDE.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
- [Latency Profiles](#latency-profiles)
2525
- [Inspecting and Clearing Latency](#inspecting-and-clearing-latency)
2626
- [Code Generation](#code-generation)
27+
- [Partial-failure error generation](#partial-failure-error-generation)
28+
- [Version-Scoped Generation](#version-scoped-generation)
29+
- [Contributing Spec Fixes Upstream](#contributing-spec-fixes-upstream)
30+
- [Generation Guards](#generation-guards)
2731
- [Demo](#demo)
2832
- [Verification Matrix](#verification-matrix)
2933
- [Individual Verifications](#individual-verifications)
@@ -493,6 +497,44 @@ Version flags accept an optional operator prefix (`>=`, `>`, `<=`, `<`). When om
493497

494498
When items are excluded by version filtering, breadcrumb comments are left in the generated code explaining the reason (e.g., `// cat.masterPath: deprecated in OpenSearch 2.0.0 (treated as removed).`). Breadcrumb visibility is configurable per category via `-version-breadcrumb-*` flags.
495499

500+
### Contributing Spec Fixes Upstream
501+
502+
Most generated code defects are not generator bugs. The spec is the input, so a missing doc comment, a wrong type, or an absent version annotation usually has to be fixed in [`opensearch-api-specification`](https://github.com/opensearch-project/opensearch-api-specification) to benefit every language client.
503+
504+
Missing doc comments are the common case. Generated types and fields carry the `description` from their schema, and the generator emits 3,060 of the 3,187 property descriptions the spec provides; the remainder simply have none upstream. To see the gaps:
505+
506+
```
507+
make report-missing-descriptions
508+
```
509+
510+
This generates into a temporary directory and prints a report to stderr, so the checked-in generated files are untouched. Output is grouped into types, struct fields, and string-enum members, with the spec component key in brackets:
511+
512+
```
513+
- SearchProcessorExecutionDetail [_core.search___ProcessorExecutionDetail]
514+
- ScrollResp.ProcessorResults json:"processor_results" [_core.search___SearchResponse]
515+
516+
SUMMARY: 1274 types, 3471 fields, 20 enum members; 4765 total
517+
```
518+
519+
The bracketed key is what to search for upstream. A gap is reported at both the reference site and the `$ref` target, so adding a description to one shared schema often resolves many lines at once.
520+
521+
> **Editing `opensearch-openapi.yaml` locally.** The vendored spec may be edited for correctness (a wrong type, a missing required field), but changes are visible to every client generated from it, so renames and cosmetic edits belong upstream rather than here. Send a corresponding PR to `opensearch-api-specification` for anything kept locally.
522+
523+
### Generation Guards
524+
525+
Two checks run before any file is written, so a regression aborts generation instead of landing in the tree. Each pins its permitted set in a reviewed, checked-in allowlist:
526+
527+
| Guard | Allowlist | What it catches |
528+
| ------------------ | ------------------------------------ | --------------------------------------------------------------------- |
529+
| `json.RawMessage` | `cmd/osgen/rawmessage_allowlist.txt` | A type the generator could not resolve, widening the raw-JSON surface |
530+
| Duplicate JSON tag | `cmd/osgen/tagshadow_allowlist.txt` | A struct redeclaring a JSON tag its embedded type already carries |
531+
532+
The duplicate-tag guard covers a defect nothing else catches. `encoding/json` resolves a duplicate tag at differing depths in favor of the shallower field, so an outer redeclaration wins and the embedded declaration is never populated -- which is how the per-hit search envelope (`_id`, `_seq_no`, `sort`) became unreachable. `go vet`'s `structtag` analyzer only checks duplicates within one struct, and `golangci-lint` relaxes generated files.
533+
534+
When a guard fails, read the offender it names and decide whether the change is intended. If it is, add the entry with `-update-tagshadow-allowlist` (or `-update-raw-message-allowlist`) and review the resulting allowlist diff as part of the change: adding an entry asserts the shadow is deliberate.
535+
536+
> **`make regen` deletes generated files before writing.** An aborted generation therefore leaves the tree empty. Recover with `git checkout -- opensearchapi/ plugins/ internal/`.
537+
496538
See [`cmd/osgen/README.md`](cmd/osgen/README.md) for the full flag reference and subcommand details.
497539

498540
## Lint

Makefile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,25 @@ gen-api-update-rawlist: fetch-opensearch-spec ## Regenerate API files and refre
283283
-remove-deprecated=$(GEN_REMOVE_DEPRECATED) \
284284
-update-raw-message-allowlist
285285

286+
report-missing-descriptions: fetch-opensearch-spec ## List generated identifiers whose OpenAPI schema has no description (upstream spec gaps)
287+
@printf "\033[2m-> Reporting generated identifiers with no OpenAPI description...\033[0m\n"
288+
@# Generation writes into a temp dir so the checked-in generated files stay
289+
@# untouched; only the stderr report matters here. OSGEN_SKIP_GIT_CHECK lets
290+
@# osgen write outside the working tree. Both allowlist checks are downgraded
291+
@# to warnings so unrelated allowlist drift cannot abort before the report.
292+
@tmp=$$(mktemp -d) && trap 'rm -rf "$$tmp"' EXIT && \
293+
cd $(REPO_ROOT)/cmd/osgen && OSGEN_SKIP_GIT_CHECK=1 go run . api \
294+
-spec $(OPENAPI_SPEC) \
295+
-out "$$tmp/opensearchapi" \
296+
-pkg opensearchapi \
297+
-plugins-out "$$tmp/plugins" \
298+
-min-version=$(GEN_MIN_VERSION) \
299+
-max-version=$(GEN_MAX_VERSION) \
300+
-remove-deprecated=$(GEN_REMOVE_DEPRECATED) \
301+
-allow-unlisted-raw-message \
302+
-allow-unlisted-tagshadow \
303+
-report-missing-descriptions
304+
286305
gen: gen-paths gen-api ## Regenerate all code from OpenAPI spec (run gen-paths and gen-api in parallel with `make -j gen`)
287306

288307
regen: clean-gen gen ## Clean generated files then regenerate from spec

_samples/usage-tasks.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,12 @@ func example() error {
120120
}
121121
fmt.Printf("Task completed: action=%s\n", taskResp.Task.Action)
122122

123-
// Read the BulkByScroll status.
124-
status := taskResp.Task.Status.BulkByScrollTaskStatus()
123+
// Read the BulkByScroll status. The accessor errors when the task's status
124+
// decoded as some other branch of the union.
125+
status, err := taskResp.Task.Status.BulkByScrollTaskStatus()
126+
if err != nil {
127+
return err
128+
}
125129

126130
fmt.Printf("Total: %d\n", status.Total)
127131
if status.Created != nil {

cmd/osapilint/internal/apirev/apirev.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,26 @@
3131
// map.
3232
package apirev
3333

34+
import "strings"
35+
3436
// Field is one exported struct field as seen by the type checker.
3537
type Field struct {
36-
Name string `json:"name"`
37-
Type string `json:"type"` // types.Type.String()
38-
IsPointer bool `json:"isPointer"` // true if the field's type is a pointer
39-
JSONTag string `json:"jsonTag,omitempty"`
38+
Name string `json:"name"`
39+
Type string `json:"type"` // types.Type.String()
40+
JSONTag string `json:"jsonTag,omitempty"`
4041
}
4142

43+
// IsPointer reports whether the field's type is a pointer. Pointer-ness is
44+
// derived from Type rather than stored in the surface, because Type is
45+
// types.Type.String() and a pointer type always renders with a leading "*" -
46+
// storing a separate flag duplicated information the surface already carried.
47+
//
48+
// The prefix test is required: a non-leading "*" shows up in slice-of-pointer
49+
// types ("[]*net/url.URL"), func types ("func(*net/http.Request)"), and inline
50+
// anonymous struct types whose embedded tag text mentions a pointer. None of
51+
// those are pointers, so a substring test would misreport them.
52+
func (f Field) IsPointer() bool { return strings.HasPrefix(f.Type, "*") }
53+
4254
// Struct is one exported struct type, qualified by its package import path so
4355
// same-named types in different packages (e.g. opensearch.Config vs
4456
// opensearchtransport.Config) never collide.

cmd/osapilint/internal/apirev/delta.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ func diffFields(sFrom, sTo Struct, dispByFrom map[string]FieldDisposition) []Fie
233233
for _, fFrom := range sFrom.Fields {
234234
fTo, still := toByName[fFrom.Name]
235235
switch {
236-
case still && fTo.IsPointer && !fFrom.IsPointer:
236+
case still && fTo.IsPointer() && !fFrom.IsPointer():
237237
changes = append(changes, FieldChange{Kind: KindPointerWrap, From: fFrom.Name, NewType: fTo.Type})
238238
case still && incompatibleTypeChange(fFrom.Type, fTo.Type):
239239
// Field kept its name but its type changed in a way that breaks

cmd/osapilint/internal/apirev/extract.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ func ExtractFromDir(dir, version string, patterns ...string) (*Snapshot, error)
7474
}
7575

7676
// extractStruct records every exported field of st, flattening embedded structs
77-
// so a field that moved into an embedded base type (e.g. v5's GetResultBase) is
78-
// still seen as present rather than mis-read as removed. Unexported fields are
79-
// skipped: the rewriter only rewrites fields a caller can name in a literal.
77+
// so a field that moved into an embedded base type (e.g. v5's
78+
// CommonAggregationsAggregateBase) is still seen as present rather than mis-read
79+
// as removed. Unexported fields are skipped: the rewriter only rewrites fields a
80+
// caller can name in a literal.
8081
func extractStruct(pkgPath, name string, st *types.Struct) Struct {
8182
s := Struct{PkgPath: pkgPath, Name: name}
8283
s.Fields = flattenFields(st, map[string]bool{})
@@ -112,12 +113,10 @@ func flattenFields(st *types.Struct, seen map[string]bool) []Field {
112113
if !f.Exported() {
113114
continue
114115
}
115-
_, isPtr := f.Type().(*types.Pointer)
116116
out = append(out, Field{
117-
Name: f.Name(),
118-
Type: f.Type().String(),
119-
IsPointer: isPtr,
120-
JSONTag: reflect.StructTag(st.Tag(i)).Get("json"),
117+
Name: f.Name(),
118+
Type: f.Type().String(),
119+
JSONTag: reflect.StructTag(st.Tag(i)).Get("json"),
121120
})
122121
}
123122
return out

cmd/osapilint/linter/argdetail_v2_to_v3_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestArgDetailV2toV3AgainstSurfaces(t *testing.T) {
6565
require.Truef(t, ok, "struct %s.%s not found in surface", pkg, structName)
6666
m := make(map[string]bool, len(st.Fields))
6767
for _, f := range st.Fields {
68-
m[f.Name] = f.IsPointer
68+
m[f.Name] = f.IsPointer()
6969
}
7070
return m
7171
}

0 commit comments

Comments
 (0)