diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7f4e15ebdca3..7ef3c81205dd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,4 @@ # https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners * @snyk/docs +tools/api-docs-generator/* @snyk/api \ No newline at end of file diff --git a/.github/workflows/sync-api-docs.yml b/.github/workflows/sync-api-docs.yml new file mode 100644 index 000000000000..00abe2cf85a1 --- /dev/null +++ b/.github/workflows/sync-api-docs.yml @@ -0,0 +1,39 @@ +name: Synchronize API Docs + +on: + workflow_dispatch: + schedule: + - cron: '0 * * * 1-5' # Mon-Fri every hour + push: + branches: [chore/docs-action] + +jobs: + build: + name: synchronize-api-docs + runs-on: ubuntu-latest + steps: + - run: | + gh auth setup-git + git config --global user.email "noreply@snyk.io" + git config --global user.name "$GITHUB_ACTOR" + gh repo clone snyk/user-docs user-docs -- --depth=1 --quiet --branch chore/docs-action + cd ./user-docs + OUTPUT=$(cd tools/api-docs-generator && go mod tidy && go run . config.yml ../../) + if [[ $(git status --porcelain) ]]; then + echo "Documentation changes detected" + git --no-pager diff --name-only + git add . + git commit -m "docs: synchronizing api spec with user-docs" + git checkout -b docs/automatic-api-docs-update + git push --force origin docs/automatic-api-docs-update + if [[ ! $(gh pr view docs/automatic-api-docs-update 2>&1 | grep -q "no open pull requests";) ]]; then + echo "Creating PR" + echo "This PR was automatically generated by the API docs synchronization action. Please review the changes and merge if they look good. \`\`\`$OUTPUT\`\`\`" > /tmp/pr_body + gh pr create --title="Generate API docs from spec" --body="$(cat /tmp/pr_body)" --head docs/automatic-api-docs-update + fi + echo "PR exists, pushed changes to it." + else + echo "No documentation changes detected, exiting." + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/tools/api-docs-generator/Makefile b/tools/api-docs-generator/Makefile new file mode 100644 index 000000000000..64d435ac8072 --- /dev/null +++ b/tools/api-docs-generator/Makefile @@ -0,0 +1,5 @@ +test: + go test ./... + +run: + go run . config.yml ../.. \ No newline at end of file diff --git a/tools/api-docs-generator/config.go b/tools/api-docs-generator/config.go new file mode 100644 index 000000000000..1a600ff3c938 --- /dev/null +++ b/tools/api-docs-generator/config.go @@ -0,0 +1,30 @@ +package main + +import ( + "gopkg.in/yaml.v3" + "os" +) + +type config struct { + Fetcher struct { + Source string `yaml:"source"` + Destination string `yaml:"destination"` + } `yaml:"fetcher"` + Specs []struct { + Path string `yaml:"path"` + Suffix string `yaml:"suffix,omitempty"` + DocsHint string `yaml:"docsHint,omitempty"` + } `yaml:"specs"` + Output struct { + APIReferencePath string `yaml:"apiReferencePath"` + } `yaml:"output"` +} + +func parseConfigFile(filename string) (config, error) { + cfg := config{} + file, err := os.Open(filename) + if err != nil { + return cfg, err + } + return cfg, yaml.NewDecoder(file).Decode(&cfg) +} diff --git a/tools/api-docs-generator/config.yml b/tools/api-docs-generator/config.yml new file mode 100644 index 000000000000..03b4b45cc859 --- /dev/null +++ b/tools/api-docs-generator/config.yml @@ -0,0 +1,12 @@ +fetcher: + source: https://api.snyk.io/rest/openapi + destination: .gitbook/assets/rest-spec.json +specs: +- path: .gitbook/assets/spec.yaml + suffix: " (v1)" + docsHint: This document uses the v1 API. For more details, see the [v1 API](../v1-api). +- path: .gitbook/assets/rest-spec.json + docsHint: This document uses the REST API. For more details, see the [Authentication for API](../authentication-for-api/) page. + +output: + apiReferencePath: snyk-api/reference \ No newline at end of file diff --git a/tools/api-docs-generator/go.mod b/tools/api-docs-generator/go.mod new file mode 100644 index 000000000000..f43e3c60e675 --- /dev/null +++ b/tools/api-docs-generator/go.mod @@ -0,0 +1,18 @@ +module github.com/snyk/user-docs/tools/api-docs-generator + +go 1.22.1 + +require ( + github.com/getkin/kin-openapi v0.124.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/swag v0.22.8 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect +) diff --git a/tools/api-docs-generator/go.sum b/tools/api-docs-generator/go.sum new file mode 100644 index 000000000000..9d94c4d22e50 --- /dev/null +++ b/tools/api-docs-generator/go.sum @@ -0,0 +1,38 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M= +github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/api-docs-generator/main.go b/tools/api-docs-generator/main.go new file mode 100644 index 000000000000..8413985a6405 --- /dev/null +++ b/tools/api-docs-generator/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "log" + "os" +) + +func main() { + if len(os.Args) != 3 { + log.Fatal("usage: api-docs ") + } + cfg, err := parseConfigFile(os.Args[1]) + if err != nil { + log.Fatal(err) + } + docsDirectory := os.Args[2] + + err = fetchSpec(cfg, docsDirectory) + if err != nil { + log.Fatal(err) + } + + err = generateReferenceDocs(cfg, docsDirectory) + if err != nil { + log.Fatal(err) + } + +} diff --git a/tools/api-docs-generator/reference_docs.go b/tools/api-docs-generator/reference_docs.go new file mode 100644 index 000000000000..00d67d061f19 --- /dev/null +++ b/tools/api-docs-generator/reference_docs.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "github.com/getkin/kin-openapi/openapi3" + "os" + "path" + "sort" + "strings" +) + +type operationPath struct { + operation *openapi3.Operation + pathItem *openapi3.PathItem + pathUrl string + specPath string + method string + docsHint string +} + +func generateReferenceDocs(config config, docsPath string) error { + aggregatedDocs := map[string][]operationPath{} + + assetPathBase := path.Join(docsPath, "docs") + + for _, spec := range config.Specs { + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile(path.Join(assetPathBase, spec.Path)) + if err != nil { + return err + } + for pathUrl, pathItem := range doc.Paths.Map() { + for method, operation := range pathItem.Operations() { + for _, tag := range operation.Tags { + tag = tag + spec.Suffix + aggregatedDocs[tag] = append(aggregatedDocs[tag], operationPath{ + operation: operation, + pathItem: pathItem, + pathUrl: pathUrl, + specPath: spec.Path, + method: method, + docsHint: spec.DocsHint, + }) + } + } + } + } + + var summary []string + for label, operation := range aggregatedDocs { + if label == "OpenAPI" { + continue + } + filePath := path.Join(docsPath, "docs/", config.Output.APIReferencePath, labelToFileName(label)) + docsFile, err := os.Create(filePath) + if err != nil { + return err + } + summary = append(summary, fmt.Sprintf("* [%s](%s)\n", label, path.Join(config.Output.APIReferencePath, labelToFileName(label)))) + + fmt.Fprintf(docsFile, `# %s + +{%% hint style="info" %%} +%s +{%% endhint %%}`, label, operation[0].docsHint) + + // sort for stability + sort.Slice(operation, func(i, j int) bool { + return operation[i].pathUrl+operation[i].method > operation[j].pathUrl+operation[j].method + }) + + for _, op := range operation { + _, err = fmt.Fprintf(docsFile, + ` +{%% swagger src="../../%s" path="%s" method="%s" %%} +[spec.yaml](../../%s) +{%% endswagger %%} +`, + op.specPath, + op.pathUrl, + strings.ToLower(op.method), + op.specPath, + ) + if err != nil { + return err + } + } + } + sort.Strings(summary) + fmt.Printf("generated menu for summary:\n") + fmt.Printf("%s", strings.Join(summary, "")) + + return nil +} + +func labelToFileName(label string) string { + replacements := []string{"(", ")"} + for _, replacement := range replacements { + label = strings.ReplaceAll(label, replacement, "") + } + return strings.ToLower(strings.ReplaceAll(label, " ", "-")) + ".md" +} diff --git a/tools/api-docs-generator/spec_fetcher.go b/tools/api-docs-generator/spec_fetcher.go new file mode 100644 index 000000000000..d8b7ef772954 --- /dev/null +++ b/tools/api-docs-generator/spec_fetcher.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "os" + "path" + "sort" + "strings" +) + +func fetchSpec(cfg config, directory string) error { + resp, err := http.Get(cfg.Fetcher.Source) + if err != nil { + return err + } + + var versions []string + + if err = json.NewDecoder(resp.Body).Decode(&versions); err != nil { + return err + } + if err = resp.Body.Close(); err != nil { + return err + } + + gaVersion := getLatestGAVersion(versions) + + specPath, err := url.JoinPath(cfg.Fetcher.Source, gaVersion) + if err != nil { + return err + } + + resp, err = http.Get(specPath) + if err != nil { + return err + } + defer resp.Body.Close() + + jsonSpec, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + formattedSpec := bytes.NewBufferString("") + err = json.Indent(formattedSpec, jsonSpec, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path.Join(directory, "docs", cfg.Fetcher.Destination), formattedSpec.Bytes(), 0644) +} + +func getLatestGAVersion(versions []string) string { + gaVersions := []string{} + for _, version := range versions { + if !strings.Contains(version, "~") { + gaVersions = append(gaVersions, version) + } + } + sort.Strings(gaVersions) + return gaVersions[len(gaVersions)-1] +} diff --git a/tools/api-docs-generator/spec_fetcher_test.go b/tools/api-docs-generator/spec_fetcher_test.go new file mode 100644 index 000000000000..3dbd884bc32c --- /dev/null +++ b/tools/api-docs-generator/spec_fetcher_test.go @@ -0,0 +1,29 @@ +package main + +import "testing" + +func Test_getLatestGAVersion(t *testing.T) { + tests := []struct { + name string + versions []string + want string + }{ + { + name: "gets the latest version", + versions: []string{"2024-03-12~experimental", "2024-03-12~beta", "2024-03-12", "2024-03-15~experimental", "2024-03-15~beta", "2024-03-15", "2024-04-11~experimental", "2024-04-11~beta", "2024-04-11", "2024-04-22~experimental", "2024-04-22~beta", "2024-04-22", "2024-04-25~experimental", "2024-04-25~beta", "2024-04-25", "2024-04-29~experimental", "2024-04-29~beta", "2024-04-29", "2024-05-08~experimental", "2024-05-08~beta", "2024-05-08"}, + want: "2024-05-08", + }, + { + name: "gets the latest GA version", + versions: []string{"2024-03-12~experimental", "2024-03-12~beta", "2024-03-12", "2024-03-15~experimental", "2024-03-15~beta", "2024-03-15", "2024-04-11~experimental", "2024-04-11~beta", "2024-04-11", "2024-04-22~experimental", "2024-04-22~beta", "2024-04-22", "2024-04-25~experimental", "2024-04-25~beta", "2024-04-25", "2024-04-29~experimental", "2024-04-29~beta", "2024-04-29", "2024-05-08~experimental", "2024-05-08~beta"}, + want: "2024-04-29", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getLatestGAVersion(tt.versions); got != tt.want { + t.Errorf("getLatestGAVersion() = %v, want %v", got, tt.want) + } + }) + } +}