github.com/grafana/pyroscope@v1.18.0/tools/api-docs-generator/main.go (about)

     1  package main
     2  
     3  import (
     4  	"encoding/json"
     5  	"flag"
     6  	"fmt"
     7  	"io"
     8  	"io/fs"
     9  	"log"
    10  	"os"
    11  	"path/filepath"
    12  	"sort"
    13  	"strings"
    14  	"text/template"
    15  
    16  	"github.com/getkin/kin-openapi/openapi3"
    17  )
    18  
    19  func main() {
    20  	var (
    21  		inputDir     = flag.String("input", "api/connect-openapi/gen", "Directory containing OpenAPI YAML files")
    22  		templateFile = flag.String("template", "docs/sources/reference-server-api/index.template", "Template flame used to generate markdown")
    23  		outputFile   = flag.String("output", "docs/sources/reference-server-api/index.md", "Output file for generated markdown")
    24  		help         = flag.Bool("help", false, "Show help")
    25  	)
    26  	flag.Parse()
    27  
    28  	if *help {
    29  		fmt.Println("API Documentation Generator")
    30  		fmt.Println()
    31  		fmt.Println("Generates unified API documentation from OpenAPI v3 YAML files.")
    32  		fmt.Println("Processes all .yaml/.yml files in the input directory and creates")
    33  		fmt.Println("a single markdown file using the provided template.")
    34  		fmt.Println()
    35  		fmt.Println("Only processes endpoints tagged with 'scope/public' and generates")
    36  		fmt.Println("both cURL and Python code examples for each endpoint.")
    37  		fmt.Println()
    38  		fmt.Println("Usage:")
    39  		fmt.Printf("  %s [flags]\n", os.Args[0])
    40  		fmt.Println()
    41  		fmt.Println("Examples:")
    42  		fmt.Printf("  %s\n", os.Args[0])
    43  		fmt.Printf("  %s -input ./specs -output ./docs/api.md\n", os.Args[0])
    44  		fmt.Println()
    45  		fmt.Println("Flags:")
    46  		flag.PrintDefaults()
    47  		return
    48  	}
    49  
    50  	if err := generateDocs(*inputDir, *templateFile, *outputFile); err != nil {
    51  		log.Fatalf("Error generating documentation: %v", err)
    52  	}
    53  
    54  	fmt.Printf("Documentation generated successfully: %s\n", *outputFile)
    55  }
    56  
    57  func generateDocs(inputDir, templateFile, outputFile string) error {
    58  	// Find all OpenAPI YAML files
    59  	yamlFiles, err := findYAMLFiles(inputDir)
    60  	if err != nil {
    61  		return fmt.Errorf("finding YAML files: %w", err)
    62  	}
    63  
    64  	if len(yamlFiles) == 0 {
    65  		return fmt.Errorf("no YAML files found in %s", inputDir)
    66  	}
    67  
    68  	// Create output directory if needed
    69  	outputDir := filepath.Dir(outputFile)
    70  	if err := os.MkdirAll(outputDir, 0755); err != nil {
    71  		return fmt.Errorf("creating output directory: %w", err)
    72  	}
    73  
    74  	// Parse all specs
    75  	loader := openapi3.NewLoader()
    76  	specs := make(map[string]*openapi3.T)
    77  	for _, file := range yamlFiles {
    78  		spec, err := loader.LoadFromFile(file)
    79  		if err != nil {
    80  			log.Printf("Warning: failed to parse %s: %v", file, err)
    81  			continue
    82  		}
    83  
    84  		// Use relative path as key
    85  		relPath, _ := filepath.Rel(inputDir, file)
    86  		specs[relPath] = spec
    87  	}
    88  
    89  	// Generate single unified documentation file
    90  	return generateUnifiedDoc(specs, templateFile, outputFile)
    91  }
    92  
    93  func findYAMLFiles(dir string) ([]string, error) {
    94  	var files []string
    95  
    96  	err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
    97  		if err != nil {
    98  			return err
    99  		}
   100  
   101  		if !d.IsDir() && (strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml")) {
   102  			files = append(files, path)
   103  		}
   104  		return nil
   105  	})
   106  
   107  	return files, err
   108  }
   109  
   110  func generateUnifiedDoc(specs map[string]*openapi3.T, templateFile string, outputFile string) error {
   111  
   112  	tmpl, err := template.ParseFiles(templateFile)
   113  	if err != nil {
   114  		return err
   115  	}
   116  
   117  	f, err := os.Create(outputFile)
   118  	if err != nil {
   119  		return err
   120  	}
   121  	defer f.Close()
   122  
   123  	err = tmpl.ExecuteTemplate(f, filepath.Base(templateFile), &templateSpecs{
   124  		Specs: specs,
   125  	})
   126  	if err != nil {
   127  		return err
   128  	}
   129  
   130  	return nil
   131  }
   132  
   133  type templateSpecs struct {
   134  	Specs map[string]*openapi3.T
   135  }
   136  
   137  func (s *templateSpecs) RenderAPIGroup(path string) string {
   138  	md := &strings.Builder{}
   139  
   140  	paths := make(map[string]*openapi3.PathItem)
   141  	pathKeys := make([]string, 0)
   142  	for _, s := range s.Specs {
   143  		for p, pItem := range s.Paths.Map() {
   144  			if strings.HasPrefix(p, path) {
   145  				_, ok := paths[p]
   146  				if ok {
   147  					panic(fmt.Sprintf("path %s already exists", p))
   148  				}
   149  				op := pItem.Get
   150  				if op == nil {
   151  					op = pItem.Post
   152  				}
   153  				if op == nil {
   154  					continue
   155  				}
   156  
   157  				// skip non public ones
   158  				public := false
   159  				for _, t := range op.Tags {
   160  					if t == "scope/public" {
   161  						public = true
   162  					}
   163  				}
   164  				if !public {
   165  					continue
   166  				}
   167  
   168  				// now add them
   169  				paths[p] = pItem
   170  				pathKeys = append(pathKeys, p)
   171  			}
   172  		}
   173  		if len(pathKeys) > 0 {
   174  			break
   175  		}
   176  	}
   177  	if len(paths) == 0 {
   178  		panic(fmt.Sprintf(`no paths found for "%s"`, path))
   179  	}
   180  	sort.Strings(pathKeys)
   181  
   182  	for _, p := range pathKeys {
   183  		pItem := paths[p]
   184  		fmt.Fprintf(md, "#### `%s`\n\n", p)
   185  		fmt.Fprintf(md, "%s\n\n", pItem.Post.Description)
   186  
   187  		s.writeParameters(md, pItem.Post)
   188  		s.writeExamples(md, p, pItem.Post)
   189  
   190  		// TODO Responses
   191  	}
   192  
   193  	return md.String()
   194  }
   195  
   196  func (s *templateSpecs) writeParameters(sb io.Writer, op *openapi3.Operation) {
   197  	if op.RequestBody == nil {
   198  		panic("no request body")
   199  	}
   200  
   201  	requestSchema := requestBodySchemaFrom(op)
   202  
   203  	fmt.Fprintln(sb, "A request body with the following fields is required:")
   204  	fmt.Fprintln(sb, "")
   205  	fmt.Fprintln(sb, "|Field | Description | Example |")
   206  	fmt.Fprintln(sb, "|:-----|:------------|:--------|")
   207  
   208  	writeSchema(sb, requestSchema)
   209  	fmt.Fprintln(sb, "")
   210  }
   211  
   212  func cleanTableField(s string) (string, bool) {
   213  	if strings.Contains(s, "[hidden]") {
   214  		return "", false
   215  	}
   216  	return strings.ReplaceAll(s, "\n", " "), true
   217  }
   218  
   219  func getExample(schema *openapi3.Schema) (string, any) {
   220  	if schema.Extensions != nil {
   221  		examples := schema.Extensions["examples"].([]any)
   222  		if len(examples) > 0 {
   223  			switch examples[0].(type) {
   224  			case string:
   225  				return examples[0].(string), examples[0]
   226  			case []any:
   227  				res, err := json.Marshal(examples[0])
   228  				if err != nil {
   229  					panic(err)
   230  				}
   231  				return string(res), examples[0]
   232  			default:
   233  				panic(fmt.Sprintf("unknown example type: %T", examples[0]))
   234  			}
   235  		}
   236  	}
   237  	return "", nil
   238  }
   239  
   240  func collectParameters(schema *openapi3.Schema, prefix string, fn func(prefix string, name string, schema *openapi3.Schema)) {
   241  	fnames := make([]string, 0, len(schema.Properties))
   242  
   243  	for f := range schema.Properties {
   244  		fnames = append(fnames, f)
   245  	}
   246  
   247  	sort.Slice(fnames, func(i, j int) bool {
   248  		// put start/end at the beginning
   249  		for _, f := range []string{"start", "end"} {
   250  			if fnames[i] == f {
   251  				return true
   252  			}
   253  			if fnames[j] == f {
   254  				return false
   255  			}
   256  		}
   257  
   258  		return fnames[i] < fnames[j]
   259  	})
   260  
   261  	for _, f := range fnames {
   262  		fSchema := schema.Properties[f].Value
   263  		if fSchema.Type.Is(openapi3.TypeObject) {
   264  			collectParameters(fSchema, prefix+f+".", fn)
   265  			continue
   266  		}
   267  		if fSchema.Type.Is(openapi3.TypeArray) && fSchema.Items.Value.Type.Is(openapi3.TypeObject) {
   268  			collectParameters(fSchema.Items.Value, prefix+f+"[].", fn)
   269  			continue
   270  		}
   271  		fn(prefix, f, fSchema)
   272  	}
   273  }
   274  
   275  func writeSchema(sb io.Writer, schema *openapi3.Schema) {
   276  	collectParameters(schema, "", func(prefix string, name string, schema *openapi3.Schema) {
   277  		description, keep := cleanTableField(schema.Description)
   278  		if !keep {
   279  			return
   280  		}
   281  		example, _ := getExample(schema)
   282  		if example != "" {
   283  			example = fmt.Sprintf("`%s`", example)
   284  		}
   285  		fmt.Fprintf(sb, "|`%s%s` | %s | %s |\n", prefix, name, description, example)
   286  	})
   287  }
   288  
   289  func requestBodySchemaFrom(op *openapi3.Operation) *openapi3.Schema {
   290  	return op.RequestBody.Value.Content["application/json"].Schema.Value
   291  }
   292  
   293  type exampleValues struct {
   294  	Curl   any
   295  	Python any
   296  }
   297  
   298  var exampleParameters = map[string]exampleValues{
   299  	// start
   300  	"1676282400000": {
   301  		Curl:   shellCmd("$(expr $(date +%s) - 3600 )000"),
   302  		Python: pythonExpr("int((datetime.datetime.now()- datetime.timedelta(hours = 1)).timestamp() * 1000)"),
   303  	},
   304  	// end
   305  	"1676289600000": {
   306  		Curl:   shellCmd("$(date +%s)000"),
   307  		Python: pythonExpr("int(datetime.datetime.now().timestamp() * 1000)"),
   308  	},
   309  	"PROFILE_BASE64": {
   310  		Curl:   shellCmd(`"$(cat cpu.pb.gz| base64 -w 0)"`),
   311  		Python: pythonExpr(`base64.b64encode(open('cpu.pb.gz', 'rb').read()).decode('ascii')`),
   312  	},
   313  }
   314  
   315  type exampleParams struct {
   316  	url string
   317  }
   318  
   319  type exampler interface {
   320  	render(io.Writer, *exampleParams)
   321  	name() string
   322  }
   323  
   324  func (s *templateSpecs) writeExamples(sb io.Writer, path string, op *openapi3.Operation) {
   325  	params := &exampleParams{
   326  		url: "http://localhost:4040" + path,
   327  	}
   328  
   329  	fmt.Fprintln(sb, "{{< code >}}")
   330  
   331  	for _, ex := range []exampler{
   332  		newExampleCurl(requestBodySchemaFrom(op)),
   333  		newExamplePython(requestBodySchemaFrom(op)),
   334  	} {
   335  		fmt.Fprintf(sb, "```%s\n", ex.name())
   336  		ex.render(sb, params)
   337  		fmt.Fprintln(sb, "```")
   338  		fmt.Fprintln(sb, "")
   339  	}
   340  
   341  	fmt.Fprintln(sb, "{{< /code >}}")
   342  }
   343  
   344  func setBody(body map[string]any, prefix string, name string, value any) {
   345  	prefixParts := strings.Split(prefix, ".")
   346  	result := body
   347  	for _, part := range prefixParts {
   348  		if part == "" {
   349  			continue
   350  		}
   351  
   352  		// handle array
   353  		if strings.HasSuffix(part, "[]") {
   354  			part = part[:len(part)-2]
   355  
   356  			var v []map[string]any
   357  			vInt, ok := result[part]
   358  			if !ok {
   359  				v = []map[string]any{{}}
   360  				result[part] = v
   361  			} else {
   362  				v = vInt.([]map[string]any)
   363  			}
   364  
   365  			if len(v) != 1 {
   366  				panic("unexpected length of array")
   367  			}
   368  
   369  			result = v[0]
   370  			continue
   371  		}
   372  		value, ok := result[part]
   373  		if !ok {
   374  			value = map[string]any{}
   375  			result[part] = value
   376  		}
   377  		result = value.(map[string]any)
   378  	}
   379  	result[name] = value
   380  }
   381  
   382  func addLabelsToSeries(body map[string]any, lbls ...string) {
   383  	if len(lbls)%2 != 0 {
   384  		panic("labels must be pairs")
   385  	}
   386  
   387  	series, ok := body["series"]
   388  	if !ok {
   389  		return
   390  	}
   391  
   392  	seriesList, ok := series.([]map[string]any)
   393  	if !ok {
   394  		return
   395  	}
   396  
   397  	for _, s := range seriesList {
   398  		labels, ok := s["labels"]
   399  		if !ok {
   400  			continue
   401  		}
   402  
   403  		labelsList, ok := labels.([]map[string]any)
   404  		if !ok {
   405  			continue
   406  		}
   407  
   408  		lbs := make([]map[string]any, 0, len(lbls)/2)
   409  		for i := 0; i < len(lbls); i += 2 {
   410  			lbs = append(lbs, map[string]any{"name": lbls[i], "value": lbls[i+1]})
   411  		}
   412  
   413  		s["labels"] = append(lbs, labelsList...)
   414  	}
   415  }