api: generate most of OpenAPI automatically (#5918)

enums and structs are now generated automatically. This eliminates some
inconsistencies and makes development easier.
This commit is contained in:
Alessandro Ros
2026-07-04 17:45:43 +02:00
committed by GitHub
parent 7b60b51881
commit 99f804d733
11 changed files with 4236 additions and 2131 deletions
+2 -14
View File
@@ -52,18 +52,6 @@ jobs:
- run: make lint-conf
go2api:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: "1.26"
- run: make lint-go2api
docslinks:
runs-on: ubuntu-24.04
@@ -88,13 +76,13 @@ jobs:
- run: make lint-docsorder
api_docs:
apidocs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- run: make lint-api-docs
- run: make lint-apidocs
other:
runs-on: ubuntu-24.04
+1
View File
@@ -16,6 +16,7 @@ help:
@echo " lint run linters"
@echo " binaries build binaries for all supported platforms"
@echo " dockerhub build and push images to Docker Hub"
@echo " apidocs generate API documentation"
@echo ""
blank :=
+1572 -1548
View File
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
// Package main contains a tool to generate openapi.yaml.
package main
import (
goast "go/ast"
goparser "go/parser"
gotoken "go/token"
"path/filepath"
"strings"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
)
var enums = []struct {
externalName string
internalName string
File string
}{
{
externalName: "ErrorStatus",
internalName: "APIErrorStatus",
File: filepath.Join("internal", "defs", "api.go"),
},
{
externalName: "AlwaysAvailableTrackCodec",
internalName: "AlwaysAvailableTrackCodec",
File: filepath.Join("internal", "conf", "always_available_track_codec.go"),
},
{
externalName: "AuthAction",
internalName: "AuthAction",
File: filepath.Join("internal", "conf", "auth_action.go"),
},
{
externalName: "AuthMethod",
internalName: "AuthMethod",
File: filepath.Join("internal", "conf", "auth_method.go"),
},
{
externalName: "Encryption",
internalName: "Encryption",
File: filepath.Join("internal", "conf", "encryption.go"),
},
{
externalName: "MoQSessionState",
internalName: "APIMoQSessionState",
File: filepath.Join("internal", "defs", "api_moq.go"),
},
{
externalName: "OKStatus",
internalName: "APIOKStatus",
File: filepath.Join("internal", "defs", "api.go"),
},
{
externalName: "PathReaderType",
internalName: "APIPathReaderType",
File: filepath.Join("internal", "defs", "api_path.go"),
},
{
externalName: "PathSourceType",
internalName: "APIPathSourceType",
File: filepath.Join("internal", "defs", "api_path.go"),
},
{
externalName: "PathTrackCodec",
internalName: "Label",
File: filepath.Join("internal", "formatlabel", "label.go"),
},
{
externalName: "RTMPConnState",
internalName: "APIRTMPConnState",
File: filepath.Join("internal", "defs", "api_rtmp.go"),
},
{
externalName: "RTSPRangeType",
internalName: "RTSPRangeType",
File: filepath.Join("internal", "conf", "rtsp_range_type.go"),
},
{
externalName: "RTSPSessionState",
internalName: "APIRTSPSessionState",
File: filepath.Join("internal", "defs", "api_rtsp.go"),
},
{
externalName: "RecordFormat",
internalName: "RecordFormat",
File: filepath.Join("internal", "conf", "record_format.go"),
},
{
externalName: "SRTConnState",
internalName: "APISRTConnState",
File: filepath.Join("internal", "defs", "api_srt.go"),
},
{
externalName: "WebRTCSessionState",
internalName: "APIWebRTCSessionState",
File: filepath.Join("internal", "defs", "api_webrtc.go"),
},
}
func extractEnumValues(name, file string) ([]string, error) {
fset := gotoken.NewFileSet()
f, err := goparser.ParseFile(fset, file, nil, 0)
if err != nil {
return nil, err
}
var values []string
for _, decl := range f.Decls {
genDecl, ok := decl.(*goast.GenDecl)
if !ok || genDecl.Tok != gotoken.CONST {
continue
}
for _, spec := range genDecl.Specs {
var valSpec *goast.ValueSpec
valSpec, ok = spec.(*goast.ValueSpec)
if !ok || valSpec.Type == nil {
continue
}
var ident *goast.Ident
ident, ok = valSpec.Type.(*goast.Ident)
if !ok || ident.Name != name {
continue
}
for _, val := range valSpec.Values {
var lit *goast.BasicLit
lit, ok = val.(*goast.BasicLit)
if !ok {
continue
}
values = append(values, strings.Trim(lit.Value, `"`))
}
}
}
return values, nil
}
func addEnums(astFile *ast.File) error {
for _, e := range enums {
values, err := extractEnumValues(e.internalName, e.File)
if err != nil {
return err
}
schema := &openAPISchema{
Type: "string",
Enum: values,
}
schemaNode, err := yaml.ValueToNode(map[string]*openAPISchema{e.externalName: schema})
if err != nil {
return err
}
indentBlockSequences(schemaNode)
addBlankLineBeforeNewSchemaEntry(schemaNode)
schemasPath, err := yaml.PathString("$.components.schemas")
if err != nil {
return err
}
err = schemasPath.MergeFromNode(astFile, schemaNode)
if err != nil {
return err
}
}
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"flag"
"fmt"
"log"
"os"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
"github.com/goccy/go-yaml/token"
)
const (
templateFile = "internal/apidocsgen/openapi.template.yaml"
outputFile = "api/openapi.yaml"
)
type openAPISchema struct {
Type string `yaml:"type,omitempty"`
Enum []string `yaml:"enum,omitempty"`
OneOf []openAPIProperty `yaml:"oneOf,omitempty"`
Properties map[string]openAPIProperty `yaml:"properties,omitempty"`
}
type openAPIProperty struct {
Ref string `yaml:"$ref,omitempty"`
Type string `yaml:"type,omitempty"`
Format string `yaml:"format,omitempty"`
Description string `yaml:"description,omitempty"`
AllOf []openAPIProperty `yaml:"allOf,omitempty"`
Nullable bool `yaml:"nullable,omitempty"`
Deprecated bool `yaml:"deprecated,omitempty"`
Enum []string `yaml:"enum,omitempty"`
Items *openAPIProperty `yaml:"items,omitempty"`
}
func indentBlockSequences(node ast.Node) {
switch n := node.(type) {
case *ast.MappingNode:
for _, value := range n.Values {
indentBlockSequences(value)
}
case *ast.MappingValueNode:
if seq, ok := n.Value.(*ast.SequenceNode); ok && !seq.IsFlowStyle && len(seq.Values) > 0 {
seq.AddColumn(2)
}
indentBlockSequences(n.Value)
case *ast.SequenceNode:
for _, value := range n.Values {
indentBlockSequences(value)
}
}
}
func addBlankLineBeforeNewSchemaEntry(schemaNode ast.Node) {
if schemasMapping, ok := schemaNode.(*ast.MappingNode); ok && len(schemasMapping.Values) > 0 {
keyTk := schemasMapping.Values[0].Key.GetToken()
keyTk.Position.Line = 3
keyTk.Prev = &token.Token{
Type: token.StringType,
Position: &token.Position{Line: 1},
}
}
}
func parseTemplate() (*ast.File, error) {
data, err := os.ReadFile(templateFile)
if err != nil {
return nil, err
}
return parser.ParseBytes(data, parser.ParseComments)
}
func generate() ([]byte, error) {
astFile, err := parseTemplate()
if err != nil {
return nil, err
}
err = addEnums(astFile)
if err != nil {
return nil, err
}
err = addStructs(astFile)
if err != nil {
return nil, err
}
return []byte(astFile.String()), nil
}
func main() {
check := flag.Bool("check", false, "check whether the generated OpenAPI matches the file on disk")
flag.Parse()
generated, err := generate()
if err != nil {
log.Printf("error: %v\n", err)
os.Exit(1)
}
if *check {
var existing []byte
existing, err = os.ReadFile(outputFile)
if err != nil {
log.Printf("error: %v\n", err)
os.Exit(1)
}
if !bytes.Equal(existing, generated) {
log.Printf("error: %v\n", fmt.Errorf("%s is outdated, run `go run ./internal/apidocsgen`", outputFile))
os.Exit(1)
}
return
}
err = os.WriteFile(outputFile, generated, 0o644)
if err != nil {
log.Printf("error: %v\n", err)
os.Exit(1)
}
}
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
package main
import (
"fmt"
goast "go/ast"
goparser "go/parser"
gotoken "go/token"
"os"
"reflect"
"strings"
"time"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"github.com/google/uuid"
)
var structs = []struct {
externalName string
typ reflect.Type
}{
{
externalName: "AlwaysAvailableTrack",
typ: reflect.TypeOf(conf.AlwaysAvailableTrack{}),
},
{
externalName: "AuthInternalUser",
typ: reflect.TypeOf(conf.AuthInternalUser{}),
},
{
externalName: "AuthInternalUserPermission",
typ: reflect.TypeOf(conf.AuthInternalUserPermission{}),
},
{
externalName: "Error",
typ: reflect.TypeOf(defs.APIError{}),
},
{
externalName: "GlobalConf",
typ: reflect.TypeOf(conf.Conf{}),
},
{
externalName: "HLSMuxer",
typ: reflect.TypeOf(defs.APIHLSMuxer{}),
},
{
externalName: "HLSMuxerList",
typ: reflect.TypeOf(defs.APIHLSMuxerList{}),
},
{
externalName: "HLSSession",
typ: reflect.TypeOf(defs.APIHLSSession{}),
},
{
externalName: "HLSSessionList",
typ: reflect.TypeOf(defs.APIHLSSessionList{}),
},
{
externalName: "Info",
typ: reflect.TypeOf(defs.APIInfo{}),
},
{
externalName: "MoQSession",
typ: reflect.TypeOf(defs.APIMoQSession{}),
},
{
externalName: "MoQSessionList",
typ: reflect.TypeOf(defs.APIMoQSessionList{}),
},
{
externalName: "OK",
typ: reflect.TypeOf(defs.APIOK{}),
},
{
externalName: "Path",
typ: reflect.TypeOf(defs.APIPath{}),
},
{
externalName: "PathConf",
typ: reflect.TypeOf(conf.Path{}),
},
{
externalName: "PathConfList",
typ: reflect.TypeOf(defs.APIPathConfList{}),
},
{
externalName: "PathList",
typ: reflect.TypeOf(defs.APIPathList{}),
},
{
externalName: "PathReader",
typ: reflect.TypeOf(defs.APIPathReader{}),
},
{
externalName: "PathSource",
typ: reflect.TypeOf(defs.APIPathSource{}),
},
{
externalName: "PathTrack",
typ: reflect.TypeOf(defs.APIPathTrack{}),
},
{
externalName: "PathTrackCodecPropsAC3",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsAC3{}),
},
{
externalName: "PathTrackCodecPropsAV1",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsAV1{}),
},
{
externalName: "PathTrackCodecPropsG711",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsG711{}),
},
{
externalName: "PathTrackCodecPropsH264",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsH264{}),
},
{
externalName: "PathTrackCodecPropsH265",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsH265{}),
},
{
externalName: "PathTrackCodecPropsLPCM",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsLPCM{}),
},
{
externalName: "PathTrackCodecPropsMPEG4Audio",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsMPEG4Audio{}),
},
{
externalName: "PathTrackCodecPropsOpus",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsOpus{}),
},
{
externalName: "PathTrackCodecPropsVP9",
typ: reflect.TypeOf(defs.APIPathTrackCodecPropsVP9{}),
},
{
externalName: "Recording",
typ: reflect.TypeOf(defs.APIRecording{}),
},
{
externalName: "RecordingList",
typ: reflect.TypeOf(defs.APIRecordingList{}),
},
{
externalName: "RecordingSegment",
typ: reflect.TypeOf(defs.APIRecordingSegment{}),
},
{
externalName: "RTMPConn",
typ: reflect.TypeOf(defs.APIRTMPConn{}),
},
{
externalName: "RTMPConnList",
typ: reflect.TypeOf(defs.APIRTMPConnList{}),
},
{
externalName: "RTSPConn",
typ: reflect.TypeOf(defs.APIRTSPConn{}),
},
{
externalName: "RTSPConnList",
typ: reflect.TypeOf(defs.APIRTSPConnsList{}),
},
{
externalName: "RTSPSession",
typ: reflect.TypeOf(defs.APIRTSPSession{}),
},
{
externalName: "RTSPSessionList",
typ: reflect.TypeOf(defs.APIRTSPSessionList{}),
},
{
externalName: "SRTConn",
typ: reflect.TypeOf(defs.APISRTConn{}),
},
{
externalName: "SRTConnList",
typ: reflect.TypeOf(defs.APISRTConnList{}),
},
{
externalName: "WebRTCICEServer",
typ: reflect.TypeOf(conf.WebRTCICEServer{}),
},
{
externalName: "WebRTCSession",
typ: reflect.TypeOf(defs.APIWebRTCSession{}),
},
{
externalName: "WebRTCSessionList",
typ: reflect.TypeOf(defs.APIWebRTCSessionList{}),
},
}
const modulePathPrefix = "github.com/bluenviron/mediamtx/"
func wrapRef(rt reflect.Type, p openAPIProperty) openAPIProperty {
if p.Ref == "" {
return p
}
if isStructEnum(rt) {
p.Type = "string"
} else if rt.Kind() == reflect.Struct {
p.Type = "object"
}
p.AllOf = []openAPIProperty{{Ref: p.Ref}}
p.Ref = ""
return p
}
func goTypeToOpenAPI(rt reflect.Type) (openAPIProperty, error) {
if rt.Kind() == reflect.Pointer {
prop, err := goTypeToOpenAPI(rt.Elem())
if err != nil {
return openAPIProperty{}, err
}
prop = wrapRef(rt.Elem(), prop)
prop.Nullable = true
return prop, nil
}
if isStructEnum(rt) {
return openAPIProperty{Ref: "#/components/schemas/" + schemaName(rt)}, nil
}
if rt == reflect.TypeOf((*defs.APIPathTrackCodecProps)(nil)).Elem() {
return openAPIProperty{
Type: "object",
AllOf: []openAPIProperty{{Ref: "#/components/schemas/" + schemaName(rt)}},
Nullable: true,
}, nil
}
switch {
case rt == reflect.TypeOf(uuid.UUID{}):
return openAPIProperty{Type: "string", Format: "uuid"}, nil
case rt == reflect.TypeOf(time.Time{}):
return openAPIProperty{Type: "string"}, nil
case rt == reflect.TypeOf(conf.Duration(0)):
return openAPIProperty{Type: "string"}, nil
case rt == reflect.TypeOf(conf.IPNetwork{}):
return openAPIProperty{Type: "string"}, nil
case rt == reflect.TypeOf(conf.StringSize(0)):
return openAPIProperty{Type: "string"}, nil
case rt == reflect.TypeOf(conf.RTSPTransports{}):
items := openAPIProperty{Type: "string", Enum: []string{"udp", "multicast", "tcp"}}
return openAPIProperty{Type: "array", Items: &items}, nil
case rt.Kind() == reflect.String:
return openAPIProperty{Type: "string"}, nil
case rt.Kind() >= reflect.Int && rt.Kind() <= reflect.Int64:
return openAPIProperty{Type: "integer", Format: "int64"}, nil
case rt.Kind() >= reflect.Uint && rt.Kind() <= reflect.Uint64:
return openAPIProperty{Type: "integer", Format: "uint64"}, nil
case rt.Kind() == reflect.Float32 || rt.Kind() == reflect.Float64:
return openAPIProperty{Type: "number", Format: "double"}, nil
case rt.Kind() == reflect.Bool:
return openAPIProperty{Type: "boolean"}, nil
case rt.Kind() == reflect.Struct:
return openAPIProperty{Ref: "#/components/schemas/" + schemaName(rt)}, nil
case rt.Kind() == reflect.Slice:
items, err := goTypeToOpenAPI(rt.Elem())
if err != nil {
return openAPIProperty{}, err
}
return openAPIProperty{Type: "array", Items: &items}, nil
default:
return openAPIProperty{}, fmt.Errorf("unhandled type: %s", rt.String())
}
}
func schemaName(rt reflect.Type) string {
if rt == reflect.TypeOf(conf.Path{}) {
return "PathConf"
}
if rt == reflect.TypeOf(defs.APIPathTrackCodec("")) {
return "PathTrackCodec"
}
if rt == reflect.TypeOf((*defs.APIPathTrackCodecProps)(nil)).Elem() {
return "PathTrackCodecProps"
}
return strings.TrimPrefix(rt.Name(), "API")
}
func isStructEnum(rt reflect.Type) bool {
switch rt {
case reflect.TypeOf(defs.APIOKStatus("")):
return true
case reflect.TypeOf(defs.APIErrorStatus("")):
return true
case reflect.TypeOf(conf.AuthAction("")):
return true
case reflect.TypeOf(conf.AlwaysAvailableTrackCodec("")):
return true
case reflect.TypeOf(conf.AuthMethod("")):
return true
case reflect.TypeOf(conf.Encryption("")):
return true
case reflect.TypeOf(conf.HLSVariant(0)):
return true
case reflect.TypeOf(conf.LogDestination(0)):
return true
case reflect.TypeOf(conf.LogLevel(0)):
return true
case reflect.TypeOf(conf.RecordFormat("")):
return true
case reflect.TypeOf(conf.RTSPAuthMethod(0)):
return true
case reflect.TypeOf(conf.RTSPRangeType("")):
return true
case reflect.TypeOf(conf.RTSPTransport{}):
return true
case reflect.TypeOf(defs.APIPathSourceType("")):
return true
case reflect.TypeOf(defs.APIPathReaderType("")):
return true
case reflect.TypeOf(defs.APIPathTrackCodec("")):
return true
case reflect.TypeOf(defs.APIRTMPConnState("")):
return true
case reflect.TypeOf(defs.APIRTSPSessionState("")):
return true
case reflect.TypeOf(defs.APIWebRTCSessionState("")):
return true
case reflect.TypeOf(defs.APIMoQSessionState("")):
return true
case reflect.TypeOf(defs.APISRTConnState("")):
return true
}
return false
}
func extractStructDescriptions(rt reflect.Type) (map[string]string, error) {
pkgPath := rt.PkgPath()
dirPath, hasModulePrefix := strings.CutPrefix(pkgPath, modulePathPrefix)
if !hasModulePrefix {
return map[string]string{}, nil
}
fset := gotoken.NewFileSet()
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") {
continue
}
filePath := dirPath + "/" + entry.Name()
file, parseErr := goparser.ParseFile(fset, filePath, nil, goparser.ParseComments)
if parseErr != nil {
return nil, parseErr
}
for _, decl := range file.Decls {
genDecl, genOK := decl.(*goast.GenDecl)
if !genOK || genDecl.Tok != gotoken.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, typeOK := spec.(*goast.TypeSpec)
if !typeOK || typeSpec.Name.Name != rt.Name() {
continue
}
structType, structOK := typeSpec.Type.(*goast.StructType)
if !structOK {
continue
}
descriptions := make(map[string]string)
for _, field := range structType.Fields.List {
if field.Tag == nil {
continue
}
jsonTag := extractStructTag(field.Tag.Value, "json")
jsonName, _, _ := strings.Cut(jsonTag, ",")
if jsonName == "" || jsonName == "-" {
continue
}
description := ""
if field.Doc != nil {
description = normalizeDescription(field.Doc.Text())
} else if field.Comment != nil {
description = normalizeDescription(field.Comment.Text())
}
if description != "" {
descriptions[jsonName] = description
}
}
return descriptions, nil
}
}
}
return map[string]string{}, nil
}
func extractStructTag(tagValue, key string) string {
tagValue = strings.Trim(tagValue, "`")
return reflect.StructTag(tagValue).Get(key)
}
func normalizeDescription(description string) string {
return strings.Join(strings.Fields(description), " ")
}
func generateStructSchema(rt reflect.Type, descriptions map[string]string) (openAPISchema, error) {
schema := openAPISchema{
Type: "object",
Properties: make(map[string]openAPIProperty),
}
for field := range rt.Fields() {
jsonTag := field.Tag.Get("json")
name, _, _ := strings.Cut(jsonTag, ",")
deprecated := field.Tag.Get("deprecated") == "true"
if name == "" || name == "-" || name == "pathDefaults" || name == "paths" ||
(strings.Contains(jsonTag, ",omitempty") && !deprecated) {
continue
}
prop, err := goTypeToOpenAPI(field.Type)
if err != nil {
return openAPISchema{}, err
}
prop.Deprecated = deprecated
if deprecated {
prop = wrapRef(field.Type, prop)
}
prop.Description = descriptions[name]
schema.Properties[name] = prop
}
return schema, nil
}
func addStructs(astFile *ast.File) error {
for _, s := range structs {
descriptions, err := extractStructDescriptions(s.typ)
if err != nil {
return err
}
schema, err := generateStructSchema(s.typ, descriptions)
if err != nil {
return err
}
schemaNode, err := yaml.ValueToNode(map[string]openAPISchema{s.externalName: schema})
if err != nil {
return err
}
indentBlockSequences(schemaNode)
addBlankLineBeforeNewSchemaEntry(schemaNode)
schemasPath, err := yaml.PathString("$.components.schemas")
if err != nil {
return err
}
err = schemasPath.MergeFromNode(astFile, schemaNode)
if err != nil {
return err
}
}
return nil
}
+1 -1
View File
@@ -69,7 +69,7 @@ type APISRTConn struct {
// The total accumulated time in microseconds, during which the SRT sender has some data to transmit,
// including packets that have been sent, but not yet acknowledged
UsSndDuration uint64 `json:"usSndDuration"`
// ??
PacketsReceivedBelated uint64 `json:"packetsReceivedBelated"`
// The total number of dropped by the SRT sender DATA packets that have no chance to be delivered in time
PacketsSendDrop uint64 `json:"packetsSendDrop"`
-560
View File
@@ -1,560 +0,0 @@
//go:build enable_linters
package main
import (
"os"
"reflect"
"strings"
"testing"
"time"
"github.com/bluenviron/mediamtx/internal/conf"
"github.com/bluenviron/mediamtx/internal/defs"
"github.com/goccy/go-yaml"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type openAPIProperty struct {
Ref string `yaml:"$ref"`
Type string `yaml:"type"`
Format string `yaml:"format"`
AllOf []openAPIProperty `yaml:"allOf"`
Nullable bool `yaml:"nullable"`
Deprecated bool `yaml:"deprecated"`
Enum []string `yaml:"enum"`
Items *openAPIProperty `yaml:"items"`
}
func wrapRef(rt reflect.Type, p openAPIProperty) openAPIProperty {
if p.Ref == "" {
return p
}
if _, ok := goEnumToApi(rt); ok {
p.Type = "string"
} else if rt.Kind() == reflect.Struct {
p.Type = "object"
}
p.AllOf = []openAPIProperty{{Ref: p.Ref}}
p.Ref = ""
return p
}
type openAPISchema struct {
Type string `yaml:"type"`
Enum []string `yaml:"enum"`
OneOf []openAPIProperty `yaml:"oneOf"`
Properties map[string]openAPIProperty `yaml:"properties"`
}
type openAPI struct {
Components struct {
Schemas map[string]openAPISchema `yaml:"schemas"`
} `yaml:"components"`
}
func schemaName(rt reflect.Type) string {
name := strings.TrimPrefix(rt.Name(), "API")
if rt.PkgPath() == "github.com/bluenviron/mediamtx/internal/conf" && name == "Path" {
return "PathConf"
}
if rt == reflect.TypeOf(defs.APIPathTrackCodec("")) {
return "PathTrackCodec"
}
return name
}
func goStructToApi(t *testing.T, rt reflect.Type) openAPIProperty {
if rt.Kind() == reflect.Pointer {
prop := goStructToApi(t, rt.Elem())
prop = wrapRef(rt.Elem(), prop)
prop.Nullable = true
return prop
}
if _, ok := goEnumToApi(rt); ok {
return openAPIProperty{Ref: "#/components/schemas/" + schemaName(rt)}
}
if rt == reflect.TypeOf((*defs.APIPathTrackCodecProps)(nil)).Elem() {
return openAPIProperty{
Type: "object",
AllOf: []openAPIProperty{{Ref: "#/components/schemas/" + schemaName(rt)}},
Nullable: true,
}
}
switch {
case rt == reflect.TypeOf(""):
return openAPIProperty{Type: "string"}
case rt == reflect.TypeOf(int(0)):
return openAPIProperty{Type: "integer", Format: "int64"}
case rt == reflect.TypeOf(uint(0)):
return openAPIProperty{Type: "integer", Format: "uint64"}
case rt == reflect.TypeOf(uint64(0)):
return openAPIProperty{Type: "integer", Format: "uint64"}
case rt == reflect.TypeOf(float64(0)):
return openAPIProperty{Type: "number", Format: "double"}
case rt == reflect.TypeOf(false):
return openAPIProperty{Type: "boolean"}
case rt == reflect.TypeOf(uuid.UUID{}):
return openAPIProperty{Type: "string", Format: "uuid"}
case rt == reflect.TypeOf(time.Time{}) ||
rt == reflect.TypeOf(conf.Duration(0)) ||
rt == reflect.TypeOf(conf.IPNetwork{}) ||
rt == reflect.TypeOf(conf.Credential("")) ||
rt == reflect.TypeOf(conf.StringSize(0)):
return openAPIProperty{Type: "string"}
case rt == reflect.TypeOf(conf.RTSPTransports{}):
return openAPIProperty{
Type: "array",
Items: &openAPIProperty{
Type: "string",
Enum: []string{"udp", "multicast", "tcp"},
},
}
case rt.Kind() == reflect.Struct:
return openAPIProperty{
Ref: "#/components/schemas/" + schemaName(rt),
}
case rt.Kind() == reflect.Slice:
items := goStructToApi(t, rt.Elem())
return openAPIProperty{
Type: "array",
Items: &items,
}
default:
t.Errorf("unhandled type: %v", rt)
return openAPIProperty{}
}
}
func goEnumToApi(rt reflect.Type) (openAPISchema, bool) {
switch rt {
case reflect.TypeOf(defs.APIOKStatus("")):
return openAPISchema{Type: "string", Enum: []string{"ok"}}, true
case reflect.TypeOf(defs.APIErrorStatus("")):
return openAPISchema{Type: "string", Enum: []string{"error"}}, true
case reflect.TypeOf(defs.APIPathSourceType("")):
return openAPISchema{Type: "string", Enum: []string{
"hlsSource",
"redirect",
"rpiCameraSource",
"rtmpConn",
"rtmpsConn",
"rtmpSource",
"rtspSession",
"rtspSource",
"rtspsSession",
"srtConn",
"srtSource",
"mpegtsSource",
"rtpSource",
"webRTCSession",
"webRTCSource",
}}, true
case reflect.TypeOf(defs.APIPathReaderType("")):
return openAPISchema{Type: "string", Enum: []string{
"hlsSession",
"rtmpConn",
"rtmpsConn",
"rtspConn",
"rtspSession",
"rtspsConn",
"rtspsSession",
"srtConn",
"webRTCSession",
}}, true
case reflect.TypeOf(defs.APIPathTrackCodec("")):
return openAPISchema{Type: "string", Enum: []string{
"AV1",
"VP9",
"VP8",
"H265",
"H264",
"MPEG-4 Video",
"MPEG-1/2 Video",
"M-JPEG",
"Opus",
"Vorbis",
"MPEG-4 Audio",
"MPEG-4 Audio LATM",
"MPEG-1/2 Audio",
"AC3",
"Speex",
"G726",
"G722",
"G711",
"LPCM",
"MPEG-TS",
"KLV",
"Generic",
}}, true
case reflect.TypeOf(conf.AlwaysAvailableTrackCodec("")):
return openAPISchema{Type: "string", Enum: []string{
"AV1",
"VP9",
"H265",
"H264",
"MPEG4Audio",
"Opus",
"G711",
"LPCM",
}}, true
case reflect.TypeOf(conf.AuthAction("")):
return openAPISchema{Type: "string", Enum: []string{
"publish",
"read",
"playback",
"api",
"metrics",
"pprof",
}}, true
case reflect.TypeOf(conf.AuthMethod("")):
return openAPISchema{Type: "string", Enum: []string{
"internal",
"http",
"jwt",
}}, true
case reflect.TypeOf(conf.Encryption("")):
return openAPISchema{Type: "string", Enum: []string{
"no",
"optional",
"strict",
}}, true
case reflect.TypeOf(conf.HLSVariant(0)):
return openAPISchema{Type: "string", Enum: []string{
"mpegts",
"fmp4",
"lowLatency",
}}, true
case reflect.TypeOf(conf.LogDestination(0)):
return openAPISchema{Type: "string", Enum: []string{
"stdout",
"file",
"syslog",
}}, true
case reflect.TypeOf(conf.LogLevel(0)):
return openAPISchema{Type: "string", Enum: []string{
"error",
"warn",
"info",
"debug",
}}, true
case reflect.TypeOf(conf.RecordFormat("")):
return openAPISchema{Type: "string", Enum: []string{
"fmp4",
"mpegts",
}}, true
case reflect.TypeOf(conf.RTSPAuthMethod(0)):
return openAPISchema{Type: "string", Enum: []string{
"basic",
"digest",
}}, true
case reflect.TypeOf(conf.RTSPRangeType("")):
return openAPISchema{Type: "string", Enum: []string{
"",
"clock",
"npt",
"smpte",
}}, true
case reflect.TypeOf(conf.RTSPTransport{}):
return openAPISchema{Type: "string", Enum: []string{
"udp",
"multicast",
"tcp",
"automatic",
}}, true
case reflect.TypeOf(defs.APIRTMPConnState("")):
return openAPISchema{Type: "string", Enum: []string{"idle", "read", "publish"}}, true
case reflect.TypeOf(defs.APIWebRTCSessionState("")):
return openAPISchema{Type: "string", Enum: []string{"read", "publish"}}, true
case reflect.TypeOf(defs.APISRTConnState("")):
return openAPISchema{Type: "string", Enum: []string{"idle", "read", "publish"}}, true
case reflect.TypeOf(defs.APIRTSPSessionState("")):
return openAPISchema{Type: "string", Enum: []string{"idle", "read", "publish"}}, true
}
return openAPISchema{}, false
}
func TestGo2API(t *testing.T) {
byts, err := os.ReadFile("../../../api/openapi.yaml")
require.NoError(t, err)
var doc openAPI
err = yaml.Unmarshal(byts, &doc)
require.NoError(t, err)
t.Run("structs", func(t *testing.T) {
for _, ca := range []struct {
openAPIKey string
goStruct any
}{
{
"AlwaysAvailableTrack",
conf.AlwaysAvailableTrack{},
},
{
"AuthInternalUser",
conf.AuthInternalUser{},
},
{
"AuthInternalUserPermission",
conf.AuthInternalUserPermission{},
},
{
"GlobalConf",
conf.Conf{},
},
{
"HLSMuxer",
defs.APIHLSMuxer{},
},
{
"HLSMuxerList",
defs.APIHLSMuxerList{},
},
{
"HLSSession",
defs.APIHLSSession{},
},
{
"HLSSessionList",
defs.APIHLSSessionList{},
},
{
"Info",
defs.APIInfo{},
},
{
"Path",
defs.APIPath{},
},
{
"PathConf",
conf.Path{},
},
{
"PathConfList",
defs.APIPathConfList{},
},
{
"PathList",
defs.APIPathList{},
},
{
"PathReader",
defs.APIPathReader{},
},
{
"PathSource",
defs.APIPathSource{},
},
{
"PathTrack",
defs.APIPathTrack{},
},
{
"PathTrackCodecPropsAV1",
defs.APIPathTrackCodecPropsAV1{},
},
{
"PathTrackCodecPropsVP9",
defs.APIPathTrackCodecPropsVP9{},
},
{
"PathTrackCodecPropsH265",
defs.APIPathTrackCodecPropsH265{},
},
{
"PathTrackCodecPropsH264",
defs.APIPathTrackCodecPropsH264{},
},
{
"PathTrackCodecPropsOpus",
defs.APIPathTrackCodecPropsOpus{},
},
{
"PathTrackCodecPropsMPEG4Audio",
defs.APIPathTrackCodecPropsMPEG4Audio{},
},
{
"PathTrackCodecPropsAC3",
defs.APIPathTrackCodecPropsAC3{},
},
{
"PathTrackCodecPropsG711",
defs.APIPathTrackCodecPropsG711{},
},
{
"PathTrackCodecPropsLPCM",
defs.APIPathTrackCodecPropsLPCM{},
},
{
"Recording",
defs.APIRecording{},
},
{
"RecordingList",
defs.APIRecordingList{},
},
{
"RecordingSegment",
defs.APIRecordingSegment{},
},
{
"RTMPConn",
defs.APIRTMPConn{},
},
{
"RTMPConnList",
defs.APIRTMPConnList{},
},
{
"RTSPConn",
defs.APIRTSPConn{},
},
{
"RTSPConnList",
defs.APIRTSPConnsList{},
},
{
"RTSPSession",
defs.APIRTSPSession{},
},
{
"RTSPSessionList",
defs.APIRTSPSessionList{},
},
{
"SRTConn",
defs.APISRTConn{},
},
{
"SRTConnList",
defs.APISRTConnList{},
},
{
"WebRTCSession",
defs.APIWebRTCSession{},
},
{
"WebRTCSessionList",
defs.APIWebRTCSessionList{},
},
} {
t.Run(ca.openAPIKey, func(t *testing.T) {
content1 := doc.Components.Schemas[ca.openAPIKey]
content2 := openAPISchema{
Type: "object",
Properties: make(map[string]openAPIProperty),
}
ty := reflect.TypeOf(ca.goStruct)
for i := range ty.NumField() {
sf := ty.Field(i)
js := sf.Tag.Get("json")
name, _, _ := strings.Cut(js, ",")
deprecated := sf.Tag.Get("deprecated") == "true"
if name != "" && name != "-" && name != "paths" && name != "pathDefaults" &&
(!strings.Contains(js, ",omitempty") || deprecated) {
prop := goStructToApi(t, sf.Type)
prop.Deprecated = deprecated
if deprecated {
prop = wrapRef(sf.Type, prop)
}
content2.Properties[name] = prop
}
}
require.Equal(t, content2, content1)
})
}
})
t.Run("oneOfs", func(t *testing.T) {
require.Equal(t, openAPISchema{OneOf: []openAPIProperty{
{Ref: "#/components/schemas/PathTrackCodecPropsAV1"},
{Ref: "#/components/schemas/PathTrackCodecPropsVP9"},
{Ref: "#/components/schemas/PathTrackCodecPropsH265"},
{Ref: "#/components/schemas/PathTrackCodecPropsH264"},
{Ref: "#/components/schemas/PathTrackCodecPropsOpus"},
{Ref: "#/components/schemas/PathTrackCodecPropsMPEG4Audio"},
{Ref: "#/components/schemas/PathTrackCodecPropsAC3"},
{Ref: "#/components/schemas/PathTrackCodecPropsG711"},
{Ref: "#/components/schemas/PathTrackCodecPropsLPCM"},
}}, doc.Components.Schemas["PathTrackCodecProps"])
})
t.Run("enums", func(t *testing.T) {
for _, rt := range []reflect.Type{
reflect.TypeOf(defs.APIOKStatus("")),
reflect.TypeOf(defs.APIErrorStatus("")),
reflect.TypeOf(defs.APIPathSourceType("")),
reflect.TypeOf(defs.APIPathReaderType("")),
reflect.TypeOf(defs.APIPathTrackCodec("")),
reflect.TypeOf(conf.AlwaysAvailableTrackCodec("")),
reflect.TypeOf(conf.AuthAction("")),
reflect.TypeOf(conf.AuthMethod("")),
reflect.TypeOf(conf.Encryption("")),
reflect.TypeOf(conf.HLSVariant(0)),
reflect.TypeOf(conf.LogDestination(0)),
reflect.TypeOf(conf.LogLevel(0)),
reflect.TypeOf(conf.RecordFormat("")),
reflect.TypeOf(conf.RTSPAuthMethod(0)),
reflect.TypeOf(conf.RTSPRangeType("")),
reflect.TypeOf(conf.RTSPTransport{}),
reflect.TypeOf(defs.APIRTMPConnState("")),
reflect.TypeOf(defs.APIRTSPSessionState("")),
reflect.TypeOf(defs.APISRTConnState("")),
reflect.TypeOf(defs.APIWebRTCSessionState("")),
} {
t.Run(rt.Name(), func(t *testing.T) {
content1 := doc.Components.Schemas[schemaName(rt)]
content2, ok := goEnumToApi(rt)
require.True(t, ok)
require.Equal(t, content2, content1)
})
}
})
}
+2
View File
@@ -0,0 +1,2 @@
apidocs:
go run ./internal/apidocsgen
+6 -8
View File
@@ -1,8 +1,8 @@
define DOCKERFILE_API_DOCS_LINT
define DOCKERFILE_APIDOCS_LINT
FROM $(NODE_IMAGE)
RUN yarn global add @redocly/cli@1.0.0-beta.123
endef
export DOCKERFILE_API_DOCS_LINT
export DOCKERFILE_APIDOCS_LINT
lint-go:
docker run --rm -v "$(shell pwd):/app" -w /app \
@@ -15,17 +15,15 @@ lint-go-mod:
lint-conf:
go test -v -tags enable_linters ./internal/linters/conf
lint-go2api:
go test -v -tags enable_linters ./internal/linters/go2api
lint-docslinks:
go test -v -tags enable_linters ./internal/linters/docslinks
lint-docsorder:
go test -v -tags enable_linters ./internal/linters/docsorder
lint-api-docs:
echo "$$DOCKERFILE_API_DOCS_LINT" | docker build . -f - -t temp
lint-apidocs:
go run ./internal/apidocsgen --check
echo "$$DOCKERFILE_APIDOCS_LINT" | docker build . -f - -t temp
docker run --rm -v "$(shell pwd)/api:/s" -w /s temp \
sh -c "openapi lint openapi.yaml"
@@ -34,4 +32,4 @@ lint-other:
docker run --rm -v "$(shell pwd)/:/s" -w /s temp \
sh -c "prettier --check ."
lint: lint-go lint-go-mod lint-conf lint-go2api lint-docslinks lint-docsorder lint-api-docs lint-other
lint: lint-go lint-go-mod lint-conf lint-docslinks lint-docsorder lint-apidocs lint-other