diff --git a/pkg/cmd/generate/clients/clients.go b/pkg/cmd/generate/clients/clients.go index 9530fc5..f1a2c3e 100644 --- a/pkg/cmd/generate/clients/clients.go +++ b/pkg/cmd/generate/clients/clients.go @@ -36,6 +36,7 @@ type ExternalDocs struct { type OperationData struct { ACL string APIName string + Beta bool CodeSamples []CodeSample Deprecated bool Description string @@ -152,8 +153,6 @@ func runCommand(opts *Options, printer *output.Printer) error { } // getAPIData reads the OpenAPI spec and parses the operation data. -// -//nolint:funlen func getAPIData( doc *libopenapi.DocumentModel[v3.Document], opts *Options, @@ -162,6 +161,11 @@ func getAPIData( count := 0 + beta, err := utils.IsBetaAPI(&doc.Model) + if err != nil { + return nil, fmt.Errorf("get beta status: %w", err) + } + prefix := fmt.Sprintf("%s/%s", opts.OutputDirectory, opts.APIName) for pathPairs := doc.Model.Paths.PathItems.First(); pathPairs != nil; pathPairs = pathPairs.Next() { @@ -174,44 +178,16 @@ func getAPIData( pathItem := pathPairs.Value() for opPairs := pathItem.GetOperations().First(); opPairs != nil; opPairs = opPairs.Next() { - op := opPairs.Value() - - acl, err := utils.GetACL(op) + data, err := buildOperationData( + opPairs.Key(), + pathName, + opPairs.Value(), + opts, + prefix, + beta, + ) if err != nil { - return nil, fmt.Errorf("get ACL for %s %s: %w", opPairs.Key(), pathName, err) - } - - short, long := utils.SplitDescription(op.Description) - short = utils.StripMarkdown(short) - - data := OperationData{ - ACL: utils.AclToString(acl), - APIName: opts.APIName, - CodeSamples: getCodeSamples(op), - Deprecated: boolOrFalse(op.Deprecated), - Description: long, - OutputFilename: utils.GetOutputFilename(op), - OutputPath: prefix, - OperationIDKebab: utils.ToKebabCase(op.OperationId), - Params: getParameters(op), - RequiresAdmin: false, - RequestBody: getRequestBody(op), - ShortDescription: short, - Summary: op.Summary, - } - - if data.ACL == "`admin`" { - data.RequiresAdmin = true - } - - if op.ExternalDocs != nil { - desc := strings.TrimSpace(op.ExternalDocs.Description) - data.ExternalDocs.Description = strings.TrimSuffix(desc, ".") - data.ExternalDocs.URL = op.ExternalDocs.URL - } - - if data.ExternalDocs.Description != "" && data.ExternalDocs.URL != "" { - data.SeeAlso = true + return nil, err } result = append(result, data) @@ -222,6 +198,60 @@ func getAPIData( return result, nil } +func buildOperationData( + verb, pathName string, + op *v3.Operation, + opts *Options, + prefix string, + beta bool, +) (OperationData, error) { + opBeta, err := utils.IsBetaOperation(op) + if err != nil { + return OperationData{}, fmt.Errorf("get beta status for %s %s: %w", verb, pathName, err) + } + + acl, err := utils.GetACL(op) + if err != nil { + return OperationData{}, fmt.Errorf("get ACL for %s %s: %w", verb, pathName, err) + } + + short, long := utils.SplitDescription(op.Description) + short = utils.StripMarkdown(short) + + data := OperationData{ + ACL: utils.AclToString(acl), + APIName: opts.APIName, + Beta: beta || opBeta, + CodeSamples: getCodeSamples(op), + Deprecated: boolOrFalse(op.Deprecated), + Description: long, + OutputFilename: utils.GetOutputFilename(op), + OutputPath: prefix, + OperationIDKebab: utils.ToKebabCase(op.OperationId), + Params: getParameters(op), + RequiresAdmin: false, + RequestBody: getRequestBody(op), + ShortDescription: short, + Summary: op.Summary, + } + + if data.ACL == "`admin`" { + data.RequiresAdmin = true + } + + if op.ExternalDocs != nil { + desc := strings.TrimSpace(op.ExternalDocs.Description) + data.ExternalDocs.Description = strings.TrimSuffix(desc, ".") + data.ExternalDocs.URL = op.ExternalDocs.URL + } + + if data.ExternalDocs.Description != "" && data.ExternalDocs.URL != "" { + data.SeeAlso = true + } + + return data, nil +} + // writeAPIData writes the OpenAPI data to MDX files. func writeAPIData( data []OperationData, diff --git a/pkg/cmd/generate/clients/clients_test.go b/pkg/cmd/generate/clients/clients_test.go index 982f29e..3f3f45d 100644 --- a/pkg/cmd/generate/clients/clients_test.go +++ b/pkg/cmd/generate/clients/clients_test.go @@ -26,6 +26,7 @@ paths: Use the [API key](https://algolia.com) endpoint with **filters:active**. This body keeps [markdown](https://algolia.com/doc) and **details** intact. + x-beta: true x-acl: - search `) @@ -78,6 +79,8 @@ paths: assertFrontmatterDescription(t, frontmatter, "Use the API key endpoint with filters:active.") assertRenderedContains(t, rendered.String(), []string{ + `import Beta from "/snippets/beta.mdx";`, + "", `description: "Use the API key endpoint with filters:active."`, "This body keeps [markdown](https://algolia.com/doc) and **details** intact.", "**Required ACL:** `search`", diff --git a/pkg/cmd/generate/clients/method.mdx.tmpl b/pkg/cmd/generate/clients/method.mdx.tmpl index b61b344..af93f5e 100644 --- a/pkg/cmd/generate/clients/method.mdx.tmpl +++ b/pkg/cmd/generate/clients/method.mdx.tmpl @@ -3,6 +3,12 @@ title: {{ .Summary }} description: {{ frontmatterString .ShortDescription }} public: true --- +{{- if .Beta }} + +import Beta from "/snippets/beta.mdx"; + + +{{- end }} {{- if .Deprecated }} This method is **deprecated.** diff --git a/pkg/cmd/generate/openapi/openapi.go b/pkg/cmd/generate/openapi/openapi.go index d4d99f4..1787f16 100644 --- a/pkg/cmd/generate/openapi/openapi.go +++ b/pkg/cmd/generate/openapi/openapi.go @@ -44,6 +44,7 @@ type OverviewData struct { type OperationData struct { ACL string APIPath string + Beta bool Description string ExternalDocs ExternalDocs InputFilename string @@ -187,6 +188,11 @@ func getAPIData( count := 0 + beta, err := utils.IsBetaAPI(&doc.Model) + if err != nil { + return nil, fmt.Errorf("get beta status: %w", err) + } + prefix := fmt.Sprintf("%s/%s", opts.OutputDirectory, opts.APIName) for pathPairs := doc.Model.Paths.PathItems.First(); pathPairs != nil; pathPairs = pathPairs.Next() { @@ -199,7 +205,14 @@ func getAPIData( pathItem := pathPairs.Value() for opPairs := pathItem.GetOperations().First(); opPairs != nil; opPairs = opPairs.Next() { - data, err := buildOperationData(opPairs.Key(), pathName, opPairs.Value(), opts, prefix) + data, err := buildOperationData( + opPairs.Key(), + pathName, + opPairs.Value(), + opts, + prefix, + beta, + ) if err != nil { return nil, err } @@ -217,6 +230,7 @@ func buildOperationData( op *v3.Operation, opts *Options, prefix string, + beta bool, ) (OperationData, error) { short, long := utils.SplitDescription(op.Description) @@ -225,9 +239,15 @@ func buildOperationData( return OperationData{}, fmt.Errorf("get ACL for %s %s: %w", verb, pathName, err) } + opBeta, err := utils.IsBetaOperation(op) + if err != nil { + return OperationData{}, fmt.Errorf("get beta status for %s %s: %w", verb, pathName, err) + } + data := OperationData{ ACL: utils.AclToString(acl), APIPath: pathName, + Beta: beta || opBeta, Description: long, InputFilename: normalizePath(opts.InputFileName), OutputFilename: utils.GetOutputFilename(op), diff --git a/pkg/cmd/generate/openapi/openapi_test.go b/pkg/cmd/generate/openapi/openapi_test.go index 96a8d59..75e462a 100644 --- a/pkg/cmd/generate/openapi/openapi_test.go +++ b/pkg/cmd/generate/openapi/openapi_test.go @@ -26,6 +26,7 @@ paths: Retrieve the [API key](https://algolia.com) with **filters:active**. Use this endpoint to fetch a key by its value with **sample** output. + x-beta: true x-acl: - search `) @@ -75,6 +76,8 @@ paths: assertFrontmatterDescription(t, frontmatter, "Retrieve the API key with filters:active.") assertRenderedContains(t, rendered, []string{ + `import Beta from "/snippets/beta.mdx";`, + "", "title: Get an API key", `description: "Retrieve the API key with filters:active."`, "Use this endpoint to fetch a key by its value with **sample** output.", diff --git a/pkg/cmd/generate/openapi/stub.mdx.tmpl b/pkg/cmd/generate/openapi/stub.mdx.tmpl index 89edd1f..2f111c5 100644 --- a/pkg/cmd/generate/openapi/stub.mdx.tmpl +++ b/pkg/cmd/generate/openapi/stub.mdx.tmpl @@ -4,6 +4,12 @@ description: {{ frontmatterString .ShortDescription }} openapi: {{ .InputFilename }} {{ .Verb }} {{ .APIPath }} public: true --- +{{- if .Beta }} + +import Beta from "/snippets/beta.mdx"; + + +{{- end }} {{- if .Description }} {{ .Description }} diff --git a/pkg/cmd/generate/utils/utils.go b/pkg/cmd/generate/utils/utils.go index 87b7759..eb21783 100644 --- a/pkg/cmd/generate/utils/utils.go +++ b/pkg/cmd/generate/utils/utils.go @@ -54,6 +54,47 @@ func GetAPIName(path string) string { return strings.TrimSuffix(base, filepath.Ext(base)) } +// IsBetaAPI returns true if the root document has an `x-beta: true` extension. +func IsBetaAPI(doc *v3.Document) (bool, error) { + if doc.Extensions == nil { + return false, nil + } + + node, ok := doc.Extensions.Get("x-beta") + if !ok { + return false, nil + } + + return parseBetaNode(node) +} + +// IsBetaOperation returns true if the operation has an `x-beta: true` extension. +func IsBetaOperation(op *v3.Operation) (bool, error) { + if op.Extensions == nil { + return false, nil + } + + node, ok := op.Extensions.Get("x-beta") + if !ok { + return false, nil + } + + return parseBetaNode(node) +} + +func parseBetaNode(node *yaml.Node) (bool, error) { + if node.Kind != yaml.ScalarNode { + return false, fmt.Errorf("expected a scalar node, got kind %d", node.Kind) + } + + var result bool + if err := node.Decode(&result); err != nil { + return false, fmt.Errorf("expected a boolean node: %w", err) + } + + return result, nil +} + // GetACL returns the ACL required to perform the given operation. func GetACL(op *v3.Operation) ([]string, error) { node, ok := op.Extensions.Get("x-acl") diff --git a/pkg/cmd/generate/utils/utils_test.go b/pkg/cmd/generate/utils/utils_test.go index 24bbc33..b0b110a 100644 --- a/pkg/cmd/generate/utils/utils_test.go +++ b/pkg/cmd/generate/utils/utils_test.go @@ -347,6 +347,162 @@ func mockOp(extensions *yaml.Node) v3.Operation { return op } +func mockApi(extensions *yaml.Node) v3.Document { + doc := v3.Document{} + doc.Extensions = orderedmap.New[string, *yaml.Node]() + doc.Extensions.Set("x-beta", extensions) + + return doc +} + +func mockBetaOp(extensions *yaml.Node) v3.Operation { + op := v3.Operation{} + op.Extensions = orderedmap.New[string, *yaml.Node]() + op.Extensions.Set("x-beta", extensions) + + return op +} + +func TestIsBetaAPI(t *testing.T) { + tests := []struct { + name string + extensions *yaml.Node + expected bool + expectError bool + errorSubstr string + }{ + { + name: "Has x-beta: true on root", + extensions: &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!bool", + Value: "true", + }, + expected: true, + }, + { + name: "Has x-beta: false on root", + extensions: &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!bool", + Value: "false", + }, + expected: false, + }, + { + name: "Extension is a sequence node", + extensions: &yaml.Node{ + Kind: yaml.SequenceNode, + }, + expected: false, + expectError: true, + errorSubstr: "expected a scalar node", + }, + { + name: "Extension is a string scalar", + extensions: &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: "true", + }, + expected: false, + expectError: true, + errorSubstr: "expected a boolean node", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + doc := mockApi(tt.extensions) + + got, err := IsBetaAPI(&doc) + if !tt.expectError && err != nil { + t.Fatalf("unexpected error %v", err) + } + + if tt.expectError { + if err == nil { + t.Fatal("error expected, but there was none") + } + + if !strings.Contains(err.Error(), tt.errorSubstr) { + t.Errorf("expected error substring %s, got %s", tt.errorSubstr, err.Error()) + } + } + + if got != tt.expected { + t.Errorf("expected %t, got %t", tt.expected, got) + } + }) + } +} + +func TestIsBetaOperation(t *testing.T) { + tests := []struct { + name string + extensions *yaml.Node + expected bool + expectError bool + errorSubstr string + }{ + { + name: "Has x-beta: true on operation", + extensions: &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!bool", + Value: "true", + }, + expected: true, + }, + { + name: "Has x-beta: false on operation", + extensions: &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!bool", + Value: "false", + }, + expected: false, + }, + { + name: "Extension is a mapping node", + extensions: &yaml.Node{ + Kind: yaml.MappingNode, + }, + expectError: true, + errorSubstr: "expected a scalar node", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + op := mockBetaOp(tt.extensions) + + got, err := IsBetaOperation(&op) + if !tt.expectError && err != nil { + t.Fatalf("unexpected error %v", err) + } + + if tt.expectError { + if err == nil { + t.Fatal("error expected, but there was none") + } + + if !strings.Contains(err.Error(), tt.errorSubstr) { + t.Errorf("expected error substring %s, got %s", tt.errorSubstr, err.Error()) + } + } + + if got != tt.expected { + t.Errorf("expected %t, got %t", tt.expected, got) + } + }) + } +} + func TestGetAcl(t *testing.T) { tests := []struct { name string