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)
+ }
+ })
+ }
+}