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 }