diff --git a/pkg/cmd/generate/clients/clients.go b/pkg/cmd/generate/clients/clients.go new file mode 100644 index 0000000..19c2343 --- /dev/null +++ b/pkg/cmd/generate/clients/clients.go @@ -0,0 +1,277 @@ +package clients + +import ( + _ "embed" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/MakeNowJust/heredoc" + "github.com/algolia/docli/pkg/cmd/generate/utils" + "github.com/algolia/docli/pkg/dictionary" + "github.com/pb33f/libopenapi" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "github.com/spf13/cobra" +) + +// Options represents configuration options and CLI flags for this command. +type Options struct { + ApiName string + InputFilename string + OutputDirectory string +} + +// OperationData represents relevant information about an API operation. +type OperationData struct { + Acl string + ApiName string + CodeSamples []CodeSample + Description string + InputFilename string + OutputFilename string + OutputPath string + OperationIdKebab string + Params []Parameter + RequestBody RequestBody + RequiresAdmin bool + ShortDescription string + Summary string +} + +type CodeSample struct { + Lang string + Label string + Source string +} + +type Parameter struct { + Name string + Description string + Required bool +} + +type RequestBody struct { + Name string + Description string +} + +//go:embed method.mdx.tmpl +var methodTemplate string + +func NewClientsCommand() *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "clients", + Aliases: []string{"c"}, + Short: "Generate MDX files for the API client method references", + Long: heredoc.Doc(` + This command reads an OpenAPI 3 spec file and generates one MDX file per operation. + It writes an API reference with usage information specific to API clients, + which may follow different conventions depending on the programming language used. + This commadn doesn't delete MDX files. If you remove or rename an operation, + you need to update or delete its MDX file manually. + `), + Example: heredoc.Doc(` + # Run from root of algolia/docs-new + docli gen clients specs/search.yml -o doc/libraries/sdk/methods + `), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + opts.InputFilename = args[0] + opts.ApiName = utils.GetApiName(opts.InputFilename) + runCommand(opts) + }, + } + + cmd.Flags(). + StringVarP(&opts.OutputDirectory, "output", "o", "out", "Output directory for generated MDX files") + + return cmd +} + +func runCommand(opts *Options) { + specFile, err := os.ReadFile(opts.InputFilename) + if err != nil { + log.Fatalf("Error: %e", err) + } + + fmt.Printf("Generating API client references for spec: %s\n", opts.InputFilename) + fmt.Printf("Writing output in: %s\n", opts.OutputDirectory) + + spec, err := utils.LoadSpec(specFile) + if err != nil { + log.Fatalf("Error: %e", err) + } + + opData, err := getApiData(spec, opts) + if err != nil { + log.Fatalf("Error: %e", err) + } + + tmpl := template.Must(template.New("method").Funcs(template.FuncMap{ + "trim": strings.TrimSpace, + }).Parse(methodTemplate)) + + writeApiData(opData, tmpl) +} + +// getApiData reads the OpenAPI spec and parses the operation data. +func getApiData( + doc *libopenapi.DocumentModel[v3.Document], + opts *Options, +) ([]OperationData, error) { + var result []OperationData + + count := 0 + + prefix := fmt.Sprintf("%s/%s", opts.OutputDirectory, opts.ApiName) + + for pathPairs := doc.Model.Paths.PathItems.First(); pathPairs != nil; pathPairs = pathPairs.Next() { + pathName := pathPairs.Key() + // Ignore custom HTTP requests + if pathName == "/{path}" { + continue + } + + pathItem := pathPairs.Value() + + for opPairs := pathItem.GetOperations().First(); opPairs != nil; opPairs = opPairs.Next() { + op := opPairs.Value() + + acl, err := utils.GetAcl(op) + if err != nil { + return nil, err + } + + short, long := splitDescription(op.Description) + + data := OperationData{ + Acl: utils.AclToString(acl), + ApiName: opts.ApiName, + CodeSamples: getCodeSamples(op), + 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 + } + + result = append(result, data) + count++ + } + } + + fmt.Printf("Spec %s has %d operations.\n", opts.InputFilename, count) + + return result, nil +} + +// writeApiData writes the OpenAPI data to MDX files. +func writeApiData(data []OperationData, template *template.Template) error { + for _, item := range data { + if err := os.MkdirAll(item.OutputPath, 0o755); err != nil { + return err + } + + fullPath := filepath.Join(item.OutputPath, item.OutputFilename) + + out, err := os.Create(fullPath) + if err != nil { + return err + } + + err = template.Execute(out, item) + if err != nil { + return err + } + } + + return nil +} + +func splitDescription(p string) (string, string) { + p = strings.TrimSpace(p) + + // Split by empty line + parts := strings.SplitN(p, "\n\n", 2) + if len(parts) > 1 && strings.TrimSpace(parts[0]) != "" { + short := strings.TrimSpace(parts[0]) + long := strings.TrimSpace(parts[1]) + + // No extra newline characters in between + short = strings.ReplaceAll(short, "\n", "") + + return short, long + } + + // No empty line: find first period + if idx := strings.Index(p, "."); idx != -1 { + short := strings.TrimSpace(p[:idx+1]) + long := strings.TrimSpace(p[idx+1:]) + + return short, long + } + + // No period: entire paragraph is the shortDescription + return p, "" +} + +func getCodeSamples(op *v3.Operation) []CodeSample { + node, ok := op.Extensions.Get("x-codeSamples") + // Operations can be without code samples + if !ok { + return nil + } + + var result []CodeSample + + for _, child := range node.Content { + var c CodeSample + + child.Decode(&c) + + c.Lang = dictionary.NormalizeLang(c.Lang) + + if strings.ToLower(c.Label) != "curl" { + result = append(result, c) + } + } + + return result +} + +func getParameters(op *v3.Operation) []Parameter { + var result []Parameter + + for _, p := range op.Parameters { + param := Parameter{ + Name: p.Name, + Description: p.Description, + } + if p.Required != nil { + param.Required = *p.Required + } + + result = append(result, param) + } + + return result +} + +func getRequestBody(op *v3.Operation) RequestBody { + return RequestBody{ + Description: "Unknown", + } +} diff --git a/pkg/cmd/generate/clients/method.mdx.tmpl b/pkg/cmd/generate/clients/method.mdx.tmpl new file mode 100644 index 0000000..e1059e7 --- /dev/null +++ b/pkg/cmd/generate/clients/method.mdx.tmpl @@ -0,0 +1,33 @@ +--- +title: {{ .Summary }} +description: {{ .ShortDescription }} +--- +{{ if .RequiresAdmin }} +**Requires Admin API key** +{{ else if .Acl }} +**Required ACL:** {{ .Acl }} +{{- end }} + +{{ .Description }} +{{ if .CodeSamples }} +## Usage + + +{{ range .CodeSamples }} +```{{ .Lang }} {{ .Label }} +{{ trim .Source }} +``` +{{ end }} + +{{- end }} + + +For more details about input parameters +and response fields. + diff --git a/pkg/cmd/generate/index.go b/pkg/cmd/generate/index.go index 505ded0..0dc1176 100644 --- a/pkg/cmd/generate/index.go +++ b/pkg/cmd/generate/index.go @@ -3,6 +3,7 @@ package generate import ( "github.com/MakeNowJust/heredoc" "github.com/algolia/docli/pkg/cmd/generate/cdn" + "github.com/algolia/docli/pkg/cmd/generate/clients" "github.com/algolia/docli/pkg/cmd/generate/openapi" "github.com/algolia/docli/pkg/cmd/generate/sla" "github.com/algolia/docli/pkg/cmd/generate/snippets" @@ -27,6 +28,7 @@ func NewGenerateCmd() *cobra.Command { `), } + command.AddCommand(clients.NewClientsCommand()) command.AddCommand(openapi.NewOpenApiCommand()) command.AddCommand(sla.NewSlaCommand()) command.AddCommand(snippets.NewSnippetsCommand()) diff --git a/pkg/cmd/generate/openapi/openapi.go b/pkg/cmd/generate/openapi/openapi.go index 8692c7e..30d055d 100644 --- a/pkg/cmd/generate/openapi/openapi.go +++ b/pkg/cmd/generate/openapi/openapi.go @@ -21,7 +21,6 @@ type Options struct { ApiName string InputFileName string OutputDirectory string - SpecFile []byte } // OperationData holds data relevant to a single API operation stub file. @@ -82,9 +81,7 @@ func runCommand(opts *Options) { fmt.Printf("Generating MDX stub files for spec: %s\n", opts.InputFileName) fmt.Printf("Writing output in: %s\n", opts.OutputDirectory) - opts.SpecFile = specFile - - spec, err := utils.LoadSpec(opts.SpecFile) + spec, err := utils.LoadSpec(specFile) if err != nil { log.Fatalf("Error: %e", err) } @@ -96,10 +93,6 @@ func runCommand(opts *Options) { tmpl := template.Must(template.New("stub").Parse(stubTemplate)) - if err != nil { - log.Fatalf("Error: %e", err) - } - writeApiData(opData, tmpl) } @@ -136,7 +129,7 @@ func getApiData( ApiPath: pathName, InputFilename: normalizePath(opts.InputFileName), OutputFilename: utils.GetOutputFilename(op), - OutputPath: utils.GetOutputPath(op, prefix), + OutputPath: prefix, RequiresAdmin: false, Title: strings.TrimSpace(op.Summary), Verb: opPairs.Key(), diff --git a/pkg/cmd/generate/snippets/snippets.go b/pkg/cmd/generate/snippets/snippets.go index a1d9890..5cca5c7 100644 --- a/pkg/cmd/generate/snippets/snippets.go +++ b/pkg/cmd/generate/snippets/snippets.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strings" "github.com/MakeNowJust/heredoc" "github.com/algolia/docli/pkg/cmd/generate/utils" @@ -85,7 +86,7 @@ func generateMarkdownSnippet(snippet map[string]string) string { for _, lang := range languages { result += fmt.Sprintf("\n```%s %s\n", lang, utils.GetLanguageName(lang)) - result += snippet[lang] + result += strings.ReplaceAll(snippet[lang], "", "ALGOLIA_INDEX_NAME") result += "\n```\n" } diff --git a/pkg/cmd/generate/utils/utils.go b/pkg/cmd/generate/utils/utils.go index f851ec7..c68c875 100644 --- a/pkg/cmd/generate/utils/utils.go +++ b/pkg/cmd/generate/utils/utils.go @@ -106,15 +106,6 @@ func LoadSpec(specFile []byte) (*libopenapi.DocumentModel[v3.Document], error) { return docModel, nil } -// GetOutputPath returns the output path for the MDX file for the given operation. -func GetOutputPath(op *v3.Operation, prefix string) string { - if len(op.Tags) > 0 { - return fmt.Sprintf("%s/%s", prefix, ToKebabCase(op.Tags[0])) - } - - return fmt.Sprintf("%s", prefix) -} - // GetOutputFilename generates the filename from the operationId. func GetOutputFilename(op *v3.Operation) string { return fmt.Sprintf("%s.mdx", ToKebabCase(op.OperationId)) diff --git a/pkg/cmd/generate/utils/utils_test.go b/pkg/cmd/generate/utils/utils_test.go index b24a1cd..e07216f 100644 --- a/pkg/cmd/generate/utils/utils_test.go +++ b/pkg/cmd/generate/utils/utils_test.go @@ -264,39 +264,6 @@ func TestOutputFilename(t *testing.T) { } } -func TestOutputPath(t *testing.T) { - prefix := "foo" - - tests := []struct { - name string - op *v3.Operation - expected string - }{ - { - name: "With tags", - op: &v3.Operation{Tags: []string{"Search"}}, - expected: "foo/search", - }, - { - name: "Without tags", - op: &v3.Operation{}, - expected: prefix, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got := GetOutputPath(tt.op, prefix) - - if got != tt.expected { - t.Errorf("Got %s, expected %s", got, tt.expected) - } - }) - } -} - func mockOp(extensions *yaml.Node) v3.Operation { op := v3.Operation{} op.Extensions = orderedmap.New[string, *yaml.Node]() diff --git a/pkg/dictionary/dictionary.go b/pkg/dictionary/dictionary.go index ec4fd06..1aef69b 100644 --- a/pkg/dictionary/dictionary.go +++ b/pkg/dictionary/dictionary.go @@ -1,6 +1,6 @@ package dictionary -// Dictionary contains strings with specific spelling or capitalization. +// dictionary contains strings with specific spelling or capitalization. var dictionary = map[string]string{ "csharp": "C#", "javascript": "JavaScript", @@ -10,6 +10,14 @@ var dictionary = map[string]string{ "typescript": "TypeScript", } +// normalizedLanguages associates language strings with normalized ones. +var normalizedLanguages = map[string]string{ + "typescript": "ts", + "javascript": "js", + "csharp": "cs", + "cURL": "sh", +} + // Translate returns the translated string if it's present in the dictionary, the original otherwise. func Translate(s string) string { if dictWord, ok := dictionary[s]; ok { @@ -18,3 +26,12 @@ func Translate(s string) string { return s } + +// NormalizeLang returns the normalized language string if it's present, the original otherwise. +func NormalizeLang(s string) string { + if word, ok := normalizedLanguages[s]; ok { + return word + } + + return s +} diff --git a/pkg/dictionary/dictionary_test.go b/pkg/dictionary/dictionary_test.go index 9de003d..bdb7432 100644 --- a/pkg/dictionary/dictionary_test.go +++ b/pkg/dictionary/dictionary_test.go @@ -37,3 +37,39 @@ func TestTranslate(t *testing.T) { }) } } + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + lang string + expected string + }{ + { + name: "Normalize csharp", + lang: "csharp", + expected: "cs", + }, + { + name: "Don't normalize C#", + lang: "C#", + expected: "C#", + }, + { + name: "Word not in dictionary", + lang: "python", + expected: "python", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := NormalizeLang(tt.lang) + + if got != tt.expected { + t.Errorf("Error in normalize: got %s, expected %s", got, tt.expected) + } + }) + } +}