Skip to content

Commit 5e9b091

Browse files
Add github action to sync api docs to the user docs site
1 parent cc2499b commit 5e9b091

File tree

11 files changed

+368
-0
lines changed

11 files changed

+368
-0
lines changed

.github/CODEOWNERS

+1
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
# https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners
1515

1616
* @snyk/docs
17+
tools/api-docs-generator/* @snyk/api

.github/workflows/sync-api-docs.yml

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Synchronize API Docs
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: '0 * * * 1-5' # Mon-Fri every hour
7+
push:
8+
branches: [chore/docs-action]
9+
10+
jobs:
11+
build:
12+
name: synchronize-api-docs
13+
runs-on: ubuntu-latest
14+
steps:
15+
- run: |
16+
gh auth setup-git
17+
git config --global user.email "[email protected]"
18+
git config --global user.name "$GITHUB_ACTOR"
19+
gh repo clone snyk/user-docs user-docs -- --depth=1 --quiet --branch chore/docs-action
20+
cd ./user-docs
21+
OUTPUT=$(cd tools/api-docs-generator && go mod tidy && go run . config.yml ../../)
22+
if [[ $(git status --porcelain) ]]; then
23+
echo "Documentation changes detected"
24+
git --no-pager diff --name-only
25+
git add .
26+
git commit -m "docs: synchronizing api spec with user-docs"
27+
git checkout -b docs/automatic-api-docs-update
28+
git push --force origin docs/automatic-api-docs-update
29+
if [[ ! $(gh pr view docs/automatic-api-docs-update 2>&1 | grep -q "no open pull requests";) ]]; then
30+
echo "Creating PR"
31+
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
32+
gh pr create --title="Generate API docs from spec" --body="$(cat /tmp/pr_body)" --head docs/automatic-api-docs-update
33+
fi
34+
echo "PR exists, pushed changes to it."
35+
else
36+
echo "No documentation changes detected, exiting."
37+
fi
38+
env:
39+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

tools/api-docs-generator/Makefile

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
test:
2+
go test ./...
3+
4+
run:
5+
go run . config.yml ../..

tools/api-docs-generator/config.go

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package main
2+
3+
import (
4+
"gopkg.in/yaml.v3"
5+
"os"
6+
)
7+
8+
type config struct {
9+
Fetcher struct {
10+
Source string `yaml:"source"`
11+
Destination string `yaml:"destination"`
12+
} `yaml:"fetcher"`
13+
Specs []struct {
14+
Path string `yaml:"path"`
15+
Suffix string `yaml:"suffix,omitempty"`
16+
DocsHint string `yaml:"docsHint,omitempty"`
17+
} `yaml:"specs"`
18+
Output struct {
19+
APIReferencePath string `yaml:"apiReferencePath"`
20+
} `yaml:"output"`
21+
}
22+
23+
func parseConfigFile(filename string) (config, error) {
24+
cfg := config{}
25+
file, err := os.Open(filename)
26+
if err != nil {
27+
return cfg, err
28+
}
29+
return cfg, yaml.NewDecoder(file).Decode(&cfg)
30+
}

tools/api-docs-generator/config.yml

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
fetcher:
2+
source: https://api.snyk.io/rest/openapi
3+
destination: .gitbook/assets/rest-spec.json
4+
specs:
5+
- path: .gitbook/assets/spec.yaml
6+
suffix: " (v1)"
7+
docsHint: This document uses the v1 API. For more details, see the [v1 API](../v1-api).
8+
- path: .gitbook/assets/rest-spec.json
9+
docsHint: This document uses the REST API. For more details, see the [Authentication for API](../authentication-for-api/) page.
10+
11+
output:
12+
apiReferencePath: snyk-api/reference

tools/api-docs-generator/go.mod

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
module github.com/snyk/user-docs/tools/api-docs-generator
2+
3+
go 1.22.1
4+
5+
require (
6+
github.com/getkin/kin-openapi v0.124.0
7+
gopkg.in/yaml.v3 v3.0.1
8+
)
9+
10+
require (
11+
github.com/go-openapi/jsonpointer v0.20.2 // indirect
12+
github.com/go-openapi/swag v0.22.8 // indirect
13+
github.com/invopop/yaml v0.2.0 // indirect
14+
github.com/josharian/intern v1.0.0 // indirect
15+
github.com/mailru/easyjson v0.7.7 // indirect
16+
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
17+
github.com/perimeterx/marshmallow v1.1.5 // indirect
18+
)

tools/api-docs-generator/go.sum

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M=
4+
github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM=
5+
github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q=
6+
github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs=
7+
github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw=
8+
github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI=
9+
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
10+
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
11+
github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY=
12+
github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
13+
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
14+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
15+
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
16+
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
17+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
18+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
19+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
20+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
21+
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
22+
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
23+
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
24+
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
25+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
26+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
27+
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
28+
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
29+
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
30+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
31+
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
32+
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
33+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
34+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
35+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
36+
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
37+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
38+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

tools/api-docs-generator/main.go

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package main
2+
3+
import (
4+
"log"
5+
"os"
6+
)
7+
8+
func main() {
9+
if len(os.Args) != 3 {
10+
log.Fatal("usage: api-docs <config-file> <docs-dir>")
11+
}
12+
cfg, err := parseConfigFile(os.Args[1])
13+
if err != nil {
14+
log.Fatal(err)
15+
}
16+
docsDirectory := os.Args[2]
17+
18+
err = fetchSpec(cfg, docsDirectory)
19+
if err != nil {
20+
log.Fatal(err)
21+
}
22+
23+
err = generateReferenceDocs(cfg, docsDirectory)
24+
if err != nil {
25+
log.Fatal(err)
26+
}
27+
28+
}
+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"github.com/getkin/kin-openapi/openapi3"
6+
"os"
7+
"path"
8+
"sort"
9+
"strings"
10+
)
11+
12+
type operationPath struct {
13+
operation *openapi3.Operation
14+
pathItem *openapi3.PathItem
15+
pathUrl string
16+
specPath string
17+
method string
18+
docsHint string
19+
}
20+
21+
func generateReferenceDocs(config config, docsPath string) error {
22+
aggregatedDocs := map[string][]operationPath{}
23+
24+
assetPathBase := path.Join(docsPath, "docs")
25+
26+
for _, spec := range config.Specs {
27+
loader := openapi3.NewLoader()
28+
doc, err := loader.LoadFromFile(path.Join(assetPathBase, spec.Path))
29+
if err != nil {
30+
return err
31+
}
32+
for pathUrl, pathItem := range doc.Paths.Map() {
33+
for method, operation := range pathItem.Operations() {
34+
for _, tag := range operation.Tags {
35+
tag = tag + spec.Suffix
36+
aggregatedDocs[tag] = append(aggregatedDocs[tag], operationPath{
37+
operation: operation,
38+
pathItem: pathItem,
39+
pathUrl: pathUrl,
40+
specPath: spec.Path,
41+
method: method,
42+
docsHint: spec.DocsHint,
43+
})
44+
}
45+
}
46+
}
47+
}
48+
49+
var summary []string
50+
for label, operation := range aggregatedDocs {
51+
if label == "OpenAPI" {
52+
continue
53+
}
54+
filePath := path.Join(docsPath, "docs/", config.Output.APIReferencePath, labelToFileName(label))
55+
docsFile, err := os.Create(filePath)
56+
if err != nil {
57+
return err
58+
}
59+
summary = append(summary, fmt.Sprintf("* [%s](%s)\n", label, path.Join(config.Output.APIReferencePath, labelToFileName(label))))
60+
61+
fmt.Fprintf(docsFile, `# %s
62+
63+
{%% hint style="info" %%}
64+
%s
65+
{%% endhint %%}`, label, operation[0].docsHint)
66+
67+
// sort for stability
68+
sort.Slice(operation, func(i, j int) bool {
69+
return operation[i].pathUrl+operation[i].method > operation[j].pathUrl+operation[j].method
70+
})
71+
72+
for _, op := range operation {
73+
_, err = fmt.Fprintf(docsFile,
74+
`
75+
{%% swagger src="../../%s" path="%s" method="%s" %%}
76+
[spec.yaml](../../%s)
77+
{%% endswagger %%}
78+
`,
79+
op.specPath,
80+
op.pathUrl,
81+
strings.ToLower(op.method),
82+
op.specPath,
83+
)
84+
if err != nil {
85+
return err
86+
}
87+
}
88+
}
89+
sort.Strings(summary)
90+
fmt.Printf("generated menu for summary:\n")
91+
fmt.Printf("%s", strings.Join(summary, ""))
92+
93+
return nil
94+
}
95+
96+
func labelToFileName(label string) string {
97+
replacements := []string{"(", ")"}
98+
for _, replacement := range replacements {
99+
label = strings.ReplaceAll(label, replacement, "")
100+
}
101+
return strings.ToLower(strings.ReplaceAll(label, " ", "-")) + ".md"
102+
}
+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"path"
11+
"sort"
12+
"strings"
13+
)
14+
15+
func fetchSpec(cfg config, directory string) error {
16+
resp, err := http.Get(cfg.Fetcher.Source)
17+
if err != nil {
18+
return err
19+
}
20+
21+
var versions []string
22+
23+
if err = json.NewDecoder(resp.Body).Decode(&versions); err != nil {
24+
return err
25+
}
26+
if err = resp.Body.Close(); err != nil {
27+
return err
28+
}
29+
30+
gaVersion := getLatestGAVersion(versions)
31+
32+
specPath, err := url.JoinPath(cfg.Fetcher.Source, gaVersion)
33+
if err != nil {
34+
return err
35+
}
36+
37+
resp, err = http.Get(specPath)
38+
if err != nil {
39+
return err
40+
}
41+
defer resp.Body.Close()
42+
43+
jsonSpec, err := io.ReadAll(resp.Body)
44+
if err != nil {
45+
return err
46+
}
47+
48+
formattedSpec := bytes.NewBufferString("")
49+
err = json.Indent(formattedSpec, jsonSpec, "", " ")
50+
if err != nil {
51+
return err
52+
}
53+
54+
return os.WriteFile(path.Join(directory, "docs", cfg.Fetcher.Destination), formattedSpec.Bytes(), 0644)
55+
}
56+
57+
func getLatestGAVersion(versions []string) string {
58+
gaVersions := []string{}
59+
for _, version := range versions {
60+
if !strings.Contains(version, "~") {
61+
gaVersions = append(gaVersions, version)
62+
}
63+
}
64+
sort.Strings(gaVersions)
65+
return gaVersions[len(gaVersions)-1]
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import "testing"
4+
5+
func Test_getLatestGAVersion(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
versions []string
9+
want string
10+
}{
11+
{
12+
name: "gets the latest version",
13+
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"},
14+
want: "2024-05-08",
15+
},
16+
{
17+
name: "gets the latest GA version",
18+
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"},
19+
want: "2024-04-29",
20+
},
21+
}
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
if got := getLatestGAVersion(tt.versions); got != tt.want {
25+
t.Errorf("getLatestGAVersion() = %v, want %v", got, tt.want)
26+
}
27+
})
28+
}
29+
}

0 commit comments

Comments
 (0)