mirror of
https://github.com/BishopFox/sj.git
synced 2026-11-08 17:09:46 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef8abdb8e | ||
|
|
f1f9e19422 | ||
|
|
0d6776b758 | ||
|
|
8f4e4a5d9f | ||
|
|
55baa79785 | ||
|
|
5bdafb64d8 | ||
|
|
27b59354fc | ||
|
|
bee5088fcf | ||
|
|
1eda960182 | ||
|
|
e805a7d190 | ||
|
|
18b20ad476 | ||
|
|
5115b3cba3 | ||
|
|
942ad84c54 | ||
|
|
878f7dcd39 | ||
|
|
275f426fc2 | ||
|
|
6d027a66ce | ||
|
|
703039dc75 | ||
|
|
e2a94378bf | ||
|
|
c9fa4f1257 | ||
|
|
90f0870bfb | ||
|
|
122d9d0114 | ||
|
|
ac50c4da75 |
No files matched your search
@@ -37,7 +37,7 @@ $ export PATH=$PATH:~/go/bin
|
||||
> Use the `automate` command to send a series of requests to each defined endpoint and analyze the status code of each response.
|
||||
|
||||
```bash
|
||||
go run . automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080
|
||||
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080
|
||||
|
||||
Gathering API details.
|
||||
Title: Swagger Petstore
|
||||
@@ -61,6 +61,18 @@ Description: This is a sample server Petstore server. You can find out more abo
|
||||
✓ GET 200 /v2/user/login
|
||||
```
|
||||
|
||||
You can use the `--replay-proxy` flag to replay matched requests through a separate proxy (e.g., Burp Suite). This lets you route all traffic through one proxy (or direct) while only sending interesting results to your interception proxy:
|
||||
|
||||
```bash
|
||||
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi --replay-proxy http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
You can also combine it with `--proxy` to route scanning traffic through a different proxy while replaying matches to Burp:
|
||||
|
||||
```bash
|
||||
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://proxy:9090 --replay-proxy http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
You can also request verbose output to see the partial (or full) response:
|
||||
|
||||
```bash
|
||||
@@ -229,6 +241,7 @@ Flags:
|
||||
-o, --outfile string Output the results to a file. Only supported for the 'automate' and 'brute' commands at this time.
|
||||
-p, --proxy string Proxy host and port. Example: http://127.0.0.1:8080 (default "NOPROXY")
|
||||
-q, --quiet Do not prompt for user input - uses default values for all requests.
|
||||
--replay-proxy string Replay matched requests using this proxy.
|
||||
--randomize-user-agent Randomizes the user agent string. Default is 'false'.
|
||||
-s, --safe-word stringArray Avoids 'dangerous word' check for the specified word(s). Multiple flags are accepted.
|
||||
-T, --target string Manually set a target for the requests to be made if separate from the host the documentation resides on.
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+10
-2
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -51,7 +52,7 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
|
||||
|
||||
var bodyBytes []byte
|
||||
|
||||
client := CheckAndConfigureProxy()
|
||||
client, replayClient := CheckAndConfigureProxy()
|
||||
|
||||
if strings.ToLower(outputFormat) != "json" {
|
||||
fmt.Printf("\n")
|
||||
@@ -65,6 +66,13 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
|
||||
if err != nil {
|
||||
die("Error opening file: %v", err)
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
|
||||
bodyBytes, _ = io.ReadAll(specFile)
|
||||
}
|
||||
@@ -73,7 +81,7 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
|
||||
log.Info("Sending requests at a rate of ", rateLimit, " requests per second.")
|
||||
}
|
||||
*/
|
||||
GenerateRequests(bodyBytes, client)
|
||||
GenerateRequests(bodyBytes, client, replayClient)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+32
-13
@@ -44,7 +44,7 @@ var bruteCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
client := CheckAndConfigureProxy()
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
|
||||
var allURLs []string
|
||||
u, err := url.Parse(swaggerURL)
|
||||
@@ -52,12 +52,13 @@ var bruteCmd = &cobra.Command{
|
||||
printWarn("Error parsing URL: %s", err)
|
||||
}
|
||||
target := u.Scheme + "://" + u.Host
|
||||
normalizedBasePath := normalizeBasePath(basePath)
|
||||
if endpointWordlist == "" {
|
||||
allURLs = append(allURLs, makeURLs(target, priorityURLs, "", true)...)
|
||||
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, "", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, javascriptEndpoints, ".js", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, ".json", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, "/", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, priorityURLs, "", true)...)
|
||||
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, javascriptEndpoints, ".js", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, ".json", false)...)
|
||||
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "/", false)...)
|
||||
} else {
|
||||
endpointList, err := os.Open(endpointWordlist)
|
||||
if err != nil {
|
||||
@@ -68,7 +69,7 @@ var bruteCmd = &cobra.Command{
|
||||
scanner := bufio.NewScanner(endpointList)
|
||||
for scanner.Scan() {
|
||||
endpoint := scanner.Text()
|
||||
fullURL := target + endpoint
|
||||
fullURL := target + normalizedBasePath + endpoint
|
||||
allURLs = append(allURLs, fullURL)
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ var bruteCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
func makeURLs(target string, endpoints []string, fileExtension string, skipPrefix bool) []string {
|
||||
func makeURLs(target string, basePath string, endpoints []string, fileExtension string, skipPrefix bool) []string {
|
||||
urls := []string{}
|
||||
if !skipPrefix {
|
||||
for _, dir := range prefixDirs {
|
||||
@@ -127,7 +128,7 @@ func makeURLs(target string, endpoints []string, fileExtension string, skipPrefi
|
||||
if dir == "" && endpoint == "" {
|
||||
continue
|
||||
}
|
||||
targetURL := target + dir + endpoint + fileExtension
|
||||
targetURL := target + basePath + dir + endpoint + fileExtension
|
||||
urls = append(urls, targetURL)
|
||||
|
||||
}
|
||||
@@ -137,7 +138,7 @@ func makeURLs(target string, endpoints []string, fileExtension string, skipPrefi
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
targetURL := target + endpoint + fileExtension
|
||||
targetURL := target + basePath + endpoint + fileExtension
|
||||
urls = append(urls, targetURL)
|
||||
}
|
||||
}
|
||||
@@ -161,18 +162,36 @@ func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
|
||||
} else if strings.Contains(ct, "application/javascript") {
|
||||
bodyBytes, bodyString, _ := MakeRequest(client, "GET", url, timeout, nil)
|
||||
if bodyBytes != nil {
|
||||
regexPattern := regexp.MustCompile(`(?s)let\s+(\w+)\s*=\s*({.*?});`)
|
||||
regexPattern := regexp.MustCompile(`(?s)(?:let|const|var)\s+(\w+)\s*=\s*({.*?});`)
|
||||
matches := regexPattern.FindAllStringSubmatch(bodyString, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
jsonContent := match[2]
|
||||
checkSpec := UnmarshalSpec([]byte(jsonContent))
|
||||
jsonContent := []byte(match[2])
|
||||
checkSpec := UnmarshalSpec(jsonContent)
|
||||
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
|
||||
printInfo("\nFound operation definitions embedded in JavaScript file at %s\n", url)
|
||||
return true, checkSpec
|
||||
}
|
||||
// Swagger UI commonly nests the spec one level deep under
|
||||
// keys like swaggerDoc (swagger-ui-express / NestJS),
|
||||
// spec (Swagger UI Bundle config), or openapi.
|
||||
var wrapper map[string]json.RawMessage
|
||||
if err := json.Unmarshal(jsonContent, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"swaggerDoc", "spec", "openapi"} {
|
||||
inner, ok := wrapper[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checkSpec := UnmarshalSpec(inner)
|
||||
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
|
||||
printInfo("\nFound operation definitions embedded in JavaScript file at %s\n", url)
|
||||
return true, checkSpec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ var convertCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
client := CheckAndConfigureProxy()
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
|
||||
if strings.ToLower(outputFormat) != "json" {
|
||||
fmt.Printf("\n")
|
||||
|
||||
+11
-3
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ This list contains the raw endpoints (parameter values will not be appended or m
|
||||
}
|
||||
}
|
||||
|
||||
client := CheckAndConfigureProxy()
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
|
||||
var bodyBytes []byte
|
||||
|
||||
@@ -29,14 +30,21 @@ This list contains the raw endpoints (parameter values will not be appended or m
|
||||
|
||||
if swaggerURL != "" {
|
||||
bodyBytes, _, _ = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
GenerateRequests(bodyBytes, client)
|
||||
GenerateRequests(bodyBytes, client, nil)
|
||||
} else {
|
||||
specFile, err := os.Open(localFile)
|
||||
if err != nil {
|
||||
die("Error opening file: %v", err)
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
bodyBytes, _ = io.ReadAll(specFile)
|
||||
GenerateRequests(bodyBytes, client)
|
||||
GenerateRequests(bodyBytes, client, nil)
|
||||
}
|
||||
},
|
||||
}
|
||||
+11
-3
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -28,21 +29,28 @@ This enables you to test specific API functions for common vulnerabilities or mi
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client := CheckAndConfigureProxy()
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
|
||||
fmt.Printf("\n")
|
||||
printInfo("Gathering API details.\n\n")
|
||||
if swaggerURL != "" {
|
||||
bodyBytes, _, _ := MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
GenerateRequests(bodyBytes, client)
|
||||
GenerateRequests(bodyBytes, client, nil)
|
||||
} else {
|
||||
specFile, err := os.Open(localFile)
|
||||
if err != nil {
|
||||
die("Error opening file: %v", err)
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
|
||||
specBytes, _ := io.ReadAll(specFile)
|
||||
GenerateRequests(specBytes, client)
|
||||
GenerateRequests(specBytes, client, nil)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
+82
-4
@@ -11,7 +11,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -66,7 +65,7 @@ func MakeRequest(client http.Client, method, target string, timeout int64, reqDa
|
||||
}
|
||||
endpoint := u.RawPath + "?" + u.RawQuery
|
||||
for _, v := range dangerousStrings {
|
||||
if os.Args[1] == "automate" && strings.Contains(endpoint, v) && !strings.Contains(strings.Join(safeWords, ","), v) {
|
||||
if os.Args[1] == "automate" && !force && strings.Contains(endpoint, v) && !strings.Contains(strings.Join(safeWords, ","), v) {
|
||||
userChoice = ""
|
||||
if avoidDangerousRequests == "y" {
|
||||
return nil, "", 0
|
||||
@@ -194,6 +193,19 @@ func CheckContentType(client http.Client, target string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
for i := range Headers {
|
||||
delimIndex := strings.Index(Headers[i], ":")
|
||||
if delimIndex == -1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(Headers[i][:delimIndex])
|
||||
value := strings.TrimSpace(Headers[i][delimIndex+1:])
|
||||
if key == "User-Agent" {
|
||||
UserAgent = value
|
||||
}
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// User-Agent handling
|
||||
if randomUserAgent {
|
||||
rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
@@ -220,7 +232,7 @@ func CheckContentType(client http.Client, target string) string {
|
||||
return resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
func CheckAndConfigureProxy() (client http.Client) {
|
||||
func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client) {
|
||||
var proxyUrl *url.URL
|
||||
|
||||
transport := &http.Transport{}
|
||||
@@ -240,5 +252,71 @@ func CheckAndConfigureProxy() (client http.Client) {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
return client
|
||||
|
||||
if replayProxy != "" {
|
||||
replayTransport := &http.Transport{}
|
||||
if insecure {
|
||||
replayTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
replayProxyUrl, err := url.Parse(replayProxy)
|
||||
if err != nil {
|
||||
die("Error parsing replay proxy URL: %v", err)
|
||||
}
|
||||
replayTransport.Proxy = http.ProxyURL(replayProxyUrl)
|
||||
replayClient = &http.Client{
|
||||
Transport: replayTransport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return client, replayClient
|
||||
}
|
||||
|
||||
func ReplayRequest(replayClient *http.Client, method, target string, timeout int64, reqData io.Reader) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequest(method, target, reqData)
|
||||
if err != nil {
|
||||
printWarn("Replay: error creating request for %s - %v", target, err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range Headers {
|
||||
delimIndex := strings.Index(Headers[i], ":")
|
||||
if delimIndex == -1 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(Headers[i][:delimIndex])
|
||||
value := strings.TrimSpace(Headers[i][delimIndex+1:])
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
|
||||
if accept == "" {
|
||||
req.Header.Set("Accept", "application/json, text/html, */*")
|
||||
} else {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
|
||||
if method == "POST" {
|
||||
if contentType == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := replayClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
printWarn("Replay: error sending request to %s - %v", target, err)
|
||||
return
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[Replay] %s %s -> %d\n", method, target, resp.StatusCode)
|
||||
}
|
||||
+5
-1
@@ -9,11 +9,13 @@ var basePath string
|
||||
var customDate string
|
||||
var customEmail string
|
||||
var customURL string
|
||||
var force bool
|
||||
var format string
|
||||
var insecure bool
|
||||
var localFile string
|
||||
var outfile string
|
||||
var proxy string
|
||||
var replayProxy string
|
||||
var quiet bool
|
||||
var randomUserAgent bool
|
||||
var rateLimit int
|
||||
@@ -51,7 +53,7 @@ $ sj convert -u https://petstore.swagger.io/v2/swagger.json -o openapi.json`,
|
||||
printErr("Command not specified. See the --help flag for usage.")
|
||||
}
|
||||
},
|
||||
Version: "2.4.0",
|
||||
Version: "2.6.2",
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
@@ -69,12 +71,14 @@ func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&customURL, "custom-url", "c", "https://bishopfox.com", "Set a custom URL to test discovered URL parameters.")
|
||||
rootCmd.PersistentFlags().StringVarP(&customDate, "custom-date", "d", "1990-01-01", "A custom date to test discovered date parameters.")
|
||||
rootCmd.PersistentFlags().StringVar(&customEmail, "custom-email", "[email protected]", "A custom email address to test discovered email parameters.")
|
||||
rootCmd.PersistentFlags().BoolVar(&force, "force", false, "Send requests without prompting, even if dangerous keywords are detected.")
|
||||
rootCmd.PersistentFlags().StringVarP(&format, "format", "f", "json", "Declare the format of the definition file (json/yaml/yml/js).")
|
||||
rootCmd.PersistentFlags().StringArrayVarP(&Headers, "headers", "H", nil, "Add custom headers, separated by a colon (\"Name: Value\"). Multiple flags are accepted.")
|
||||
rootCmd.PersistentFlags().BoolVarP(&insecure, "insecure", "i", false, "Ignores server certificate validation.")
|
||||
rootCmd.PersistentFlags().StringVarP(&localFile, "local-file", "l", "", "Loads the documentation from a local file.")
|
||||
rootCmd.PersistentFlags().StringVarP(&outfile, "outfile", "o", "", "Output the results to a file. Only supported for the 'automate' and 'brute' commands at this time.")
|
||||
rootCmd.PersistentFlags().StringVarP(&proxy, "proxy", "p", "NOPROXY", "Proxy host and port. Example: http://127.0.0.1:8080")
|
||||
rootCmd.PersistentFlags().StringVar(&replayProxy, "replay-proxy", "", "Replay matched requests using this proxy.")
|
||||
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Do not prompt for user input - uses default values for all requests.")
|
||||
rootCmd.PersistentFlags().BoolVar(&randomUserAgent, "randomize-user-agent", false, "Randomizes the user agent string. Default is 'false'.")
|
||||
// rootCmd.PersistentFlags().IntVarP(&rateLimit, "rate", "r", 15, "Limit the number of requests per second.") // NEED TO RE-IMPLEMENT RATE LIMIT
|
||||
|
||||
+406
-135
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -19,27 +20,46 @@ var jsonResultArray []Result
|
||||
var jsonVerboseResultArray []VerboseResult
|
||||
var specTitle string
|
||||
var specDescription string
|
||||
var externalRefCache = make(map[string]map[string]interface{})
|
||||
var specToFilePath = make(map[*interface{}]string) // Maps external spec pointers to their file paths
|
||||
var specBaseDir string // Directory of the loaded spec file for resolving external refs
|
||||
|
||||
type SchemaNode struct {
|
||||
Type string
|
||||
Properties map[string]*SchemaNode
|
||||
Items *SchemaNode
|
||||
Required map[string]bool
|
||||
Enum []interface{}
|
||||
Example interface{}
|
||||
Ref string
|
||||
Type string
|
||||
Properties map[string]*SchemaNode
|
||||
Items *SchemaNode
|
||||
Required map[string]bool
|
||||
Enum []interface{}
|
||||
Example interface{}
|
||||
Ref string
|
||||
OneOf []*SchemaNode
|
||||
AnyOf []*SchemaNode
|
||||
AdditionalProperties *SchemaNode
|
||||
}
|
||||
|
||||
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, replayClient *http.Client) {
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok || paths == nil {
|
||||
die("Could not find any defined operations. Review the file manually.")
|
||||
}
|
||||
|
||||
pathKeys := make([]string, 0, len(paths))
|
||||
for k := range paths {
|
||||
pathKeys = append(pathKeys, k)
|
||||
}
|
||||
slices.Sort(pathKeys)
|
||||
|
||||
var errorDescriptions = make(map[any]string)
|
||||
for pathName, pathItem := range paths {
|
||||
for _, pathName := range pathKeys {
|
||||
pathItem := paths[pathName]
|
||||
if ops, ok := pathItem.(map[string]interface{}); ok {
|
||||
for method, op := range ops {
|
||||
methodKeys := make([]string, 0, len(ops))
|
||||
for k := range ops {
|
||||
methodKeys = append(methodKeys, k)
|
||||
}
|
||||
slices.Sort(methodKeys)
|
||||
for _, method := range methodKeys {
|
||||
op := ops[method]
|
||||
switch strings.ToLower(method) {
|
||||
// SKIPS THE "DELETE" AND "PATCH" METHODS FOR SAFETY
|
||||
case "delete":
|
||||
@@ -66,97 +86,110 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
// Extracts the expected parameters from the parameters object
|
||||
if params, ok := opMap["parameters"].([]interface{}); ok {
|
||||
for _, p := range params {
|
||||
var pType string
|
||||
var pValue string
|
||||
|
||||
// Handle parameter-level $ref (e.g., #/parameters/... or #/components/parameters/...)
|
||||
if pMap, ok := p.(map[string]interface{}); ok {
|
||||
// Check if the parameter itself is a reference
|
||||
paramContextSpec := spec
|
||||
if ref, hasRef := pMap["$ref"].(string); hasRef {
|
||||
resolved, contextSpec := ResolveRefWithContext(spec, ref)
|
||||
if resolved != nil {
|
||||
pMap = resolved
|
||||
paramContextSpec = contextSpec
|
||||
}
|
||||
}
|
||||
|
||||
if name, ok := pMap["name"].(string); ok {
|
||||
in := pMap["in"].(string)
|
||||
|
||||
// Handle schema-based parameters (OpenAPI v3 and some v2)
|
||||
var handledAsObject bool // Track if we already handled this as an object
|
||||
if schema, ok := pMap["schema"].(map[string]interface{}); ok {
|
||||
if schema["type"] != nil {
|
||||
pType = schema["type"].(string)
|
||||
// Attempt to prevent version strings from being improperly supplied (i.e. v1)
|
||||
if pType == "string" && pMap["name"] != "version" {
|
||||
expanded := ExpandSchema(spec, schema, map[string]bool{}, paramContextSpec)
|
||||
if expanded.Type == "object" || len(expanded.Properties) > 0 {
|
||||
// For object schemas, generate full example and serialize
|
||||
example := GenerateExample(expanded)
|
||||
if exampleMap, ok := example.(map[string]interface{}); ok {
|
||||
// Handle based on parameter location
|
||||
if in == "query" {
|
||||
// Query params with object schema: add each property to query string
|
||||
for propertyItem, propertyValue := range exampleMap {
|
||||
pVal := fmt.Sprintf("%v", propertyValue)
|
||||
if strings.Contains(curl, "?") || strings.Contains(targetURL, "?") {
|
||||
targetURL += fmt.Sprintf("&%s=%s", propertyItem, pVal)
|
||||
} else {
|
||||
targetURL += fmt.Sprintf("?%s=%s", propertyItem, pVal)
|
||||
}
|
||||
}
|
||||
handledAsObject = true
|
||||
} else if in == "body" {
|
||||
// Body params with object schema: add each property to body data
|
||||
for propertyItem, propertyValue := range exampleMap {
|
||||
pVal := fmt.Sprintf("%v", propertyValue)
|
||||
if strings.Contains(curl, "-d '") {
|
||||
bodyData += fmt.Sprintf("&%s=%s", propertyItem, pVal)
|
||||
curl = strings.TrimSuffix(curl, "'")
|
||||
curl += fmt.Sprintf("&%s=%s'", propertyItem, pVal)
|
||||
} else {
|
||||
bodyData += fmt.Sprintf("%s=%s", propertyItem, pVal)
|
||||
curl += fmt.Sprintf(" -d '%s=%s'", propertyItem, pVal)
|
||||
}
|
||||
}
|
||||
handledAsObject = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For primitive types, generate simple value
|
||||
exampleValue := GenerateExample(expanded)
|
||||
if expanded.Type == "string" && name != "version" {
|
||||
pValue = testString
|
||||
} else if exampleValue != nil {
|
||||
pValue = fmt.Sprintf("%v", exampleValue)
|
||||
} else {
|
||||
pValue = "1"
|
||||
}
|
||||
} else if schema["$ref"] != nil {
|
||||
ref := schema["$ref"].(string)
|
||||
if defs, ok := spec["definitions"].(map[string]interface{}); ok {
|
||||
schemaName := strings.TrimPrefix(ref, "#/definitions/")
|
||||
if s, ok := defs[schemaName].(map[string]interface{}); ok {
|
||||
if properties, ok := s["properties"].(map[string]interface{}); ok {
|
||||
for propertyItem := range properties {
|
||||
var pValue string
|
||||
if propertyName, ok := properties[propertyItem].(map[string]interface{}); ok {
|
||||
if exampleValue, ok := propertyName["example"]; ok {
|
||||
switch ex := exampleValue.(type) {
|
||||
case string:
|
||||
pValue = exampleValue.(string)
|
||||
case []interface{}:
|
||||
if len(ex) > 0 {
|
||||
if v, ok := ex[0].(string); ok {
|
||||
pValue = v
|
||||
} else {
|
||||
pValue = "1"
|
||||
}
|
||||
}
|
||||
default:
|
||||
pValue = "1"
|
||||
}
|
||||
} else if propertyType, ok := propertyName["type"].(string); ok {
|
||||
// Attempt to prevent version strings from being improperly supplied (i.e. v1)
|
||||
if propertyType == "string" && propertyName["name"] != "version" {
|
||||
pValue = testString
|
||||
} else {
|
||||
pValue = "1"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if strings.Contains(curl, "-d '") {
|
||||
bodyData += fmt.Sprintf("&%s=%s", propertyItem, pValue)
|
||||
curl = strings.TrimSuffix(curl, "'")
|
||||
curl += fmt.Sprintf("&%s=%s'", propertyItem, pValue)
|
||||
} else {
|
||||
bodyData += fmt.Sprintf("%s=%s", propertyItem, pValue)
|
||||
curl += fmt.Sprintf(" -d '%s=%s'", propertyItem, pValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if pType, ok := pMap["type"].(string); ok {
|
||||
// Attempt to prevent version strings from being improperly supplied (i.e. v1)
|
||||
if pType == "string" && pMap["name"] != "version" {
|
||||
// Direct type without schema (Swagger v2 style)
|
||||
// Check for default value first
|
||||
if defaultVal := pMap["default"]; defaultVal != nil {
|
||||
pValue = fmt.Sprintf("%v", defaultVal)
|
||||
} else if pType == "string" && name != "version" {
|
||||
pValue = testString
|
||||
} else {
|
||||
pValue = "1"
|
||||
}
|
||||
} else if defaultVal := pMap["default"]; defaultVal != nil {
|
||||
// Use default value if no type or schema
|
||||
pValue = fmt.Sprintf("%v", defaultVal)
|
||||
} else {
|
||||
// Fallback to generic value
|
||||
pValue = "1"
|
||||
}
|
||||
|
||||
switch in {
|
||||
case "query":
|
||||
if strings.Contains(curl, "?") || strings.Contains(targetURL, "?") {
|
||||
targetURL += fmt.Sprintf("&%s=%s", name, pValue)
|
||||
} else {
|
||||
targetURL += fmt.Sprintf("?%s=%s", name, pValue)
|
||||
}
|
||||
case "path":
|
||||
targetURL = strings.Replace(targetURL, "{"+name+"}", pValue, 1)
|
||||
case "header":
|
||||
curl += fmt.Sprintf(" -H \"%s: %s\"", name, pValue)
|
||||
case "body":
|
||||
if strings.Contains(curl, "-d '") {
|
||||
bodyData += fmt.Sprintf("&%s=%s", name, pValue)
|
||||
curl = strings.TrimSuffix(curl, "'")
|
||||
curl += fmt.Sprintf("&%s=%s'", name, pValue)
|
||||
} else {
|
||||
bodyData += fmt.Sprintf("%s=%s", name, pValue)
|
||||
curl += fmt.Sprintf(" -d '%s=%s'", name, pValue)
|
||||
// Only process parameters that weren't already handled as objects
|
||||
if !handledAsObject {
|
||||
switch in {
|
||||
case "query":
|
||||
if strings.Contains(curl, "?") || strings.Contains(targetURL, "?") {
|
||||
targetURL += fmt.Sprintf("&%s=%s", name, pValue)
|
||||
} else {
|
||||
targetURL += fmt.Sprintf("?%s=%s", name, pValue)
|
||||
}
|
||||
case "path":
|
||||
targetURL = strings.Replace(targetURL, "{"+name+"}", pValue, 1)
|
||||
case "header":
|
||||
curl += fmt.Sprintf(" -H \"%s: %s\"", name, pValue)
|
||||
case "body":
|
||||
if strings.Contains(curl, "-d '") {
|
||||
bodyData += fmt.Sprintf("&%s=%s", name, pValue)
|
||||
curl = strings.TrimSuffix(curl, "'")
|
||||
curl += fmt.Sprintf("&%s=%s'", name, pValue)
|
||||
} else {
|
||||
bodyData += fmt.Sprintf("%s=%s", name, pValue)
|
||||
curl += fmt.Sprintf(" -d '%s=%s'", name, pValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +199,16 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
|
||||
// Extracts the expected parameters from the requestBody object
|
||||
if reqBody, ok := opMap["requestBody"].(map[string]interface{}); ok {
|
||||
// Handle requestBody-level $ref (e.g., #/components/requestBodies/...)
|
||||
reqBodyContextSpec := spec
|
||||
if ref, hasRef := reqBody["$ref"].(string); hasRef {
|
||||
resolved, contextSpec := ResolveRefWithContext(spec, ref)
|
||||
if resolved != nil {
|
||||
reqBody = resolved
|
||||
reqBodyContextSpec = contextSpec
|
||||
}
|
||||
}
|
||||
|
||||
if contentTypes, ok := reqBody["content"].(map[string]interface{}); ok {
|
||||
for cType := range contentTypes {
|
||||
if contentType == "" {
|
||||
@@ -176,13 +219,13 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
|
||||
if ct, ok := contentTypes[cType].(map[string]interface{}); ok {
|
||||
if schema, ok := ct["schema"].(map[string]interface{}); ok {
|
||||
expanded := ExpandSchema(spec, schema, map[string]bool{})
|
||||
expanded := ExpandSchema(spec, schema, map[string]bool{}, reqBodyContextSpec)
|
||||
example := GenerateExample(expanded)
|
||||
|
||||
if cType == "application/json" {
|
||||
bodyBytes, err := json.Marshal(example)
|
||||
if err == nil {
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: application/json\" -d '%s'\"", bodyBytes)
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: application/json\" -d '%s'", bodyBytes)
|
||||
}
|
||||
}
|
||||
if cType == "application/xml" || cType == "text/xml" {
|
||||
@@ -191,12 +234,29 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, xml)
|
||||
}
|
||||
}
|
||||
if cType == "application/x-www-form-urlencoded" || cType == "multipart/form-data" {
|
||||
if obj, ok := example.(map[string]interface{}); ok {
|
||||
var formParts []string
|
||||
for k, v := range obj {
|
||||
formParts = append(formParts, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
formData := strings.Join(formParts, "&")
|
||||
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, formData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the curl command with the final targetURL (which may have been modified with query params)
|
||||
// Extract and replace the URL in quotes
|
||||
curlParts := strings.SplitN(curl, "\"", 3)
|
||||
if len(curlParts) >= 3 {
|
||||
curl = curlParts[0] + "\"" + targetURL + "\"" + curlParts[2]
|
||||
}
|
||||
|
||||
logURL, parseErr := url.Parse(targetURL)
|
||||
if parseErr != nil || logURL == nil {
|
||||
printWarn("Error parsing URL '%s': %v - skipping endpoint.", targetURL, parseErr)
|
||||
@@ -236,6 +296,9 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
if outputFormat == "console" {
|
||||
writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength])
|
||||
}
|
||||
if replayClient != nil {
|
||||
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if jsonResultsStringArray == nil {
|
||||
@@ -246,6 +309,9 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
if outputFormat == "console" {
|
||||
writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength])
|
||||
}
|
||||
if replayClient != nil {
|
||||
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
|
||||
}
|
||||
}
|
||||
|
||||
case "endpoints":
|
||||
@@ -270,7 +336,6 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
|
||||
}
|
||||
}
|
||||
if os.Args[1] == "automate" && outputFormat == "json" {
|
||||
slices.Sort(jsonResultsStringArray)
|
||||
for r := range jsonResultsStringArray {
|
||||
var result Result
|
||||
var verboseResult VerboseResult
|
||||
@@ -312,6 +377,7 @@ func ExpandSchema(
|
||||
spec map[string]interface{},
|
||||
schema map[string]interface{},
|
||||
visited map[string]bool,
|
||||
contextSpec map[string]interface{}, // The spec document this schema belongs to (for resolving nested refs)
|
||||
) *SchemaNode {
|
||||
if schema == nil {
|
||||
return &SchemaNode{Type: "object"}
|
||||
@@ -323,11 +389,13 @@ func ExpandSchema(
|
||||
}
|
||||
visited[ref] = true
|
||||
|
||||
resolved := ResolveRef(spec, ref)
|
||||
// Resolve ref in the context spec (could be external)
|
||||
resolved, resolvedSpec := ResolveRefWithContext(contextSpec, ref)
|
||||
if resolved == nil {
|
||||
return &SchemaNode{Type: "object"}
|
||||
}
|
||||
return ExpandSchema(spec, resolved, visited)
|
||||
// Continue expansion using the resolved spec as context
|
||||
return ExpandSchema(spec, resolved, visited, resolvedSpec)
|
||||
}
|
||||
|
||||
node := &SchemaNode{
|
||||
@@ -339,35 +407,103 @@ func ExpandSchema(
|
||||
node.Type = t
|
||||
}
|
||||
|
||||
// Handle enum values
|
||||
if enum, ok := schema["enum"].([]interface{}); ok {
|
||||
node.Enum = enum
|
||||
}
|
||||
|
||||
// Handle example values
|
||||
if example := schema["example"]; example != nil {
|
||||
node.Example = example
|
||||
}
|
||||
|
||||
// Populate required fields
|
||||
if required, ok := schema["required"].([]interface{}); ok {
|
||||
for _, r := range required {
|
||||
if fieldName, ok := r.(string); ok {
|
||||
node.Required[fieldName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if props, ok := schema["properties"].(map[string]interface{}); ok {
|
||||
for name, raw := range props {
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
node.Properties[name] = ExpandSchema(spec, m, visited)
|
||||
node.Properties[name] = ExpandSchema(spec, m, visited, contextSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if items, ok := schema["items"].(map[string]interface{}); ok {
|
||||
node.Items = ExpandSchema(spec, items, visited)
|
||||
node.Items = ExpandSchema(spec, items, visited, contextSpec)
|
||||
}
|
||||
|
||||
// Handle additionalProperties
|
||||
if addProps, ok := schema["additionalProperties"]; ok {
|
||||
if addPropsMap, ok := addProps.(map[string]interface{}); ok {
|
||||
node.AdditionalProperties = ExpandSchema(spec, addPropsMap, visited, contextSpec)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle allOf (merge all schemas)
|
||||
if allOf, ok := schema["allOf"].([]interface{}); ok {
|
||||
merged := &SchemaNode{Type: "object", Properties: map[string]*SchemaNode{}}
|
||||
merged := &SchemaNode{Type: "object", Properties: map[string]*SchemaNode{}, Required: map[string]bool{}}
|
||||
for _, entry := range allOf {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
sub := ExpandSchema(spec, m, visited)
|
||||
sub := ExpandSchema(spec, m, visited, contextSpec)
|
||||
for k, v := range sub.Properties {
|
||||
merged.Properties[k] = v
|
||||
}
|
||||
for k, v := range sub.Required {
|
||||
merged.Required[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Handle oneOf (expand all options)
|
||||
if oneOf, ok := schema["oneOf"].([]interface{}); ok {
|
||||
for _, entry := range oneOf {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
node.OneOf = append(node.OneOf, ExpandSchema(spec, m, visited, contextSpec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle anyOf (expand all options)
|
||||
if anyOf, ok := schema["anyOf"].([]interface{}); ok {
|
||||
for _, entry := range anyOf {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
node.AnyOf = append(node.AnyOf, ExpandSchema(spec, m, visited, contextSpec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
func GenerateExample(node *SchemaNode) interface{} {
|
||||
// Use example value if available
|
||||
if node.Example != nil {
|
||||
return node.Example
|
||||
}
|
||||
|
||||
// Use first enum value if available
|
||||
if len(node.Enum) > 0 {
|
||||
return node.Enum[0]
|
||||
}
|
||||
|
||||
// Handle oneOf - use first option
|
||||
if len(node.OneOf) > 0 {
|
||||
return GenerateExample(node.OneOf[0])
|
||||
}
|
||||
|
||||
// Handle anyOf - use first option
|
||||
if len(node.AnyOf) > 0 {
|
||||
return GenerateExample(node.AnyOf[0])
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case "object", "":
|
||||
obj := map[string]interface{}{}
|
||||
@@ -382,6 +518,10 @@ func GenerateExample(node *SchemaNode) interface{} {
|
||||
obj[k] = GenerateExample(v)
|
||||
}
|
||||
}
|
||||
// Handle additionalProperties if present and no regular properties
|
||||
if len(obj) == 0 && node.AdditionalProperties != nil {
|
||||
obj["additionalProp1"] = GenerateExample(node.AdditionalProperties)
|
||||
}
|
||||
return obj
|
||||
case "array":
|
||||
if node.Items != nil {
|
||||
@@ -399,7 +539,7 @@ func GenerateExample(node *SchemaNode) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateRequests(bodyBytes []byte, client http.Client) {
|
||||
func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.Client) {
|
||||
// Ingests the specification file
|
||||
spec := SafelyUnmarshalSpec(bodyBytes)
|
||||
|
||||
@@ -411,53 +551,84 @@ func GenerateRequests(bodyBytes []byte, client http.Client) {
|
||||
u = &url.URL{}
|
||||
}
|
||||
|
||||
// Gets the target server and base path from the specification file
|
||||
if apiTarget == "" {
|
||||
if v, ok := spec["swagger"].(string); ok && strings.HasPrefix(v, "2") {
|
||||
// Swagger (v2)
|
||||
host, _ := spec["host"].(string)
|
||||
bp, _ := spec["basePath"].(string)
|
||||
if bp == "/" {
|
||||
basePath = ""
|
||||
} else if bp != "" {
|
||||
basePath = bp
|
||||
}
|
||||
// Parse basePath and server info from spec
|
||||
// Always extract basePath, even if -T was used (so -T sets host but spec sets path)
|
||||
if v, ok := spec["swagger"].(string); ok && strings.HasPrefix(v, "2") {
|
||||
// Swagger (v2)
|
||||
host, _ := spec["host"].(string)
|
||||
bp, _ := spec["basePath"].(string)
|
||||
if bp != "" {
|
||||
basePath = normalizeBasePath(bp)
|
||||
}
|
||||
|
||||
// Only set apiTarget from spec if -T flag wasn't used
|
||||
if apiTarget == "" {
|
||||
if host != "" && strings.Contains(host, "://") {
|
||||
apiTarget = host
|
||||
} else {
|
||||
if host != "" {
|
||||
apiTarget = u.Scheme + "://" + host
|
||||
scheme := u.Scheme
|
||||
// If scheme is empty (e.g., local file), try to get from spec's schemes array
|
||||
if scheme == "" {
|
||||
if schemes, ok := spec["schemes"].([]interface{}); ok && len(schemes) > 0 {
|
||||
if s, ok := schemes[0].(string); ok {
|
||||
scheme = s
|
||||
}
|
||||
}
|
||||
}
|
||||
// Default to https if still no scheme
|
||||
if scheme == "" {
|
||||
scheme = "https"
|
||||
}
|
||||
apiTarget = scheme + "://" + host
|
||||
}
|
||||
}
|
||||
} else if v, ok := spec["openapi"].(string); ok && strings.HasPrefix(v, "3") {
|
||||
// OpenAPI (v3)
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if len(servers) > 1 {
|
||||
if !quiet && (os.Args[1] != "endpoints") && apiTarget == "" {
|
||||
printWarn("Multiple servers detected in documentation. You can manually set a server to test with the -T flag.\nThe detected servers are as follows:")
|
||||
for i := range servers {
|
||||
if srv, ok := servers[i].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
fmt.Println(serverURL)
|
||||
} else {
|
||||
fmt.Println(apiTarget + serverURL)
|
||||
}
|
||||
}
|
||||
} else if v, ok := spec["openapi"].(string); ok && strings.HasPrefix(v, "3") {
|
||||
// OpenAPI (v3)
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if len(servers) > 1 {
|
||||
if !quiet && (os.Args[1] != "endpoints") && apiTarget == "" {
|
||||
printWarn("Multiple servers detected in documentation. You can manually set a server to test with the -T flag.\nThe detected servers are as follows:")
|
||||
for i := range servers {
|
||||
if srv, ok := servers[i].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
fmt.Println(serverURL)
|
||||
} else {
|
||||
fmt.Println(apiTarget + serverURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
apiTarget = serverURL
|
||||
} else if serverURL == "/" {
|
||||
basePath = ""
|
||||
} else {
|
||||
basePath = serverURL
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
}
|
||||
} else {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
// Full URL in server
|
||||
if parsedServerURL, err := url.Parse(serverURL); err == nil {
|
||||
basePath = normalizeBasePath(parsedServerURL.Path)
|
||||
if apiTarget == "" {
|
||||
apiTarget = parsedServerURL.Scheme + "://" + parsedServerURL.Host
|
||||
}
|
||||
}
|
||||
} else if serverURL == "/" {
|
||||
basePath = ""
|
||||
} else {
|
||||
// Relative URL - this becomes the basePath
|
||||
basePath = normalizeBasePath(serverURL)
|
||||
// Only try to construct apiTarget if -T wasn't used
|
||||
if apiTarget == "" {
|
||||
if u.Scheme != "" && u.Host != "" {
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
} else {
|
||||
// Local file with relative server URL and no -T flag
|
||||
// Only fail for commands that need full URLs
|
||||
if os.Args[1] != "endpoints" {
|
||||
die("Spec has relative server URL '%s' but no base URL available. Use -T to specify target server.", serverURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -468,7 +639,15 @@ func GenerateRequests(bodyBytes []byte, client http.Client) {
|
||||
|
||||
// Use the original host at the target if no server found from specification.
|
||||
if apiTarget == "" {
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
if u.Scheme != "" && u.Host != "" {
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
} else {
|
||||
// No server info and no URL to parse - require user to specify target
|
||||
// Only fail for commands that need full URLs
|
||||
if os.Args[1] != "endpoints" {
|
||||
die("No server information found in spec and no URL provided. Use -T to specify target server.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if os.Args[1] != "endpoints" {
|
||||
@@ -477,12 +656,56 @@ func GenerateRequests(bodyBytes []byte, client http.Client) {
|
||||
}
|
||||
|
||||
// Reviews all defined API routes and builds requests as defined
|
||||
BuildRequestsFromPaths(spec, client)
|
||||
BuildRequestsFromPaths(spec, client, replayClient)
|
||||
}
|
||||
|
||||
func ResolveRef(spec map[string]interface{}, ref string) map[string]interface{} {
|
||||
resolved, _ := ResolveRefWithContext(spec, ref)
|
||||
return resolved
|
||||
}
|
||||
|
||||
func normalizeBasePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "/" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
path = strings.TrimRight(path, "/")
|
||||
if path == "/" {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ResolveRefWithContext resolves a reference and returns both the resolved schema and the spec it came from
|
||||
func ResolveRefWithContext(spec map[string]interface{}, ref string) (map[string]interface{}, map[string]interface{}) {
|
||||
// Handle external references (e.g., "./schemas/user.yaml#/User")
|
||||
if !strings.HasPrefix(ref, "#") {
|
||||
return nil
|
||||
// Determine the base directory for resolving this external ref
|
||||
baseDir := specBaseDir
|
||||
// Check if the current spec is an external file
|
||||
for cachedPath, cachedSpec := range externalRefCache {
|
||||
if fmt.Sprintf("%p", cachedSpec) == fmt.Sprintf("%p", spec) {
|
||||
// This spec is an external file, use its directory as base
|
||||
baseDir = filepath.Dir(cachedPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resolved := ResolveExternalRef(ref, baseDir)
|
||||
// For external refs, the resolved schema's context is itself
|
||||
// (nested refs within it should be resolved in the external doc)
|
||||
if resolved != nil {
|
||||
// Try to get the cached external spec as context
|
||||
filePath := strings.SplitN(ref, "#", 2)[0]
|
||||
fullPath := filepath.Clean(filepath.Join(baseDir, filePath))
|
||||
if externalSpec, exists := externalRefCache[fullPath]; exists {
|
||||
return resolved, externalSpec
|
||||
}
|
||||
}
|
||||
return resolved, spec
|
||||
}
|
||||
|
||||
parts := strings.Split(ref[2:], "/")
|
||||
@@ -491,13 +714,61 @@ func ResolveRef(spec map[string]interface{}, ref string) map[string]interface{}
|
||||
for _, p := range parts {
|
||||
m, ok := cur.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
return nil, spec
|
||||
}
|
||||
cur = m[p]
|
||||
}
|
||||
|
||||
resolved, _ := cur.(map[string]interface{})
|
||||
return resolved
|
||||
// Internal refs stay in the same spec context
|
||||
return resolved, spec
|
||||
}
|
||||
|
||||
func ResolveExternalRef(ref string, baseDir string) map[string]interface{} {
|
||||
// Parse external reference format: "<file_path>#<json_pointer>"
|
||||
parts := strings.SplitN(ref, "#", 2)
|
||||
if len(parts) < 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
relativePath := parts[0]
|
||||
var jsonPointer string
|
||||
if len(parts) == 2 {
|
||||
jsonPointer = parts[1]
|
||||
}
|
||||
|
||||
// Resolve path relative to the provided base directory
|
||||
filePath := filepath.Join(baseDir, relativePath)
|
||||
filePath = filepath.Clean(filePath)
|
||||
|
||||
// Check cache first
|
||||
if cached, exists := externalRefCache[filePath]; exists {
|
||||
if jsonPointer == "" {
|
||||
return cached
|
||||
}
|
||||
// Resolve pointer within cached file
|
||||
return ResolveRef(cached, "#"+jsonPointer)
|
||||
}
|
||||
|
||||
// Load external file
|
||||
fileData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
externalSpec := SafelyUnmarshalSpec(fileData)
|
||||
if externalSpec == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache the loaded file
|
||||
externalRefCache[filePath] = externalSpec
|
||||
|
||||
// Resolve pointer if present
|
||||
if jsonPointer == "" {
|
||||
return externalSpec
|
||||
}
|
||||
return ResolveRef(externalSpec, "#"+jsonPointer)
|
||||
}
|
||||
|
||||
func PrintSpecInfo(spec map[string]interface{}) {
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSwaggerV2SchemeHandling(t *testing.T) {
|
||||
oldSwaggerURL := swaggerURL
|
||||
oldAPITarget := apiTarget
|
||||
oldSpecBaseDir := specBaseDir
|
||||
defer func() {
|
||||
swaggerURL = oldSwaggerURL
|
||||
apiTarget = oldAPITarget
|
||||
specBaseDir = oldSpecBaseDir
|
||||
}()
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v2.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
swaggerURL = ""
|
||||
apiTarget = ""
|
||||
|
||||
if schemes, ok := spec["schemes"].([]interface{}); ok && len(schemes) > 0 {
|
||||
if scheme, ok := schemes[0].(string); ok {
|
||||
if scheme != "https" && scheme != "http" {
|
||||
t.Errorf("Expected http or https scheme, got: %s", scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if host, ok := spec["host"].(string); !ok || host == "" {
|
||||
t.Error("Expected host field in Swagger v2 spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalReferenceResolution(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldCache := externalRefCache
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
externalRefCache = oldCache
|
||||
}()
|
||||
|
||||
externalRefCache = make(map[string]map[string]interface{})
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_spec_external.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
ref := "external_schemas.yaml#/components/schemas/Customer"
|
||||
resolved := ResolveExternalRef(ref, specBaseDir)
|
||||
|
||||
if resolved == nil {
|
||||
t.Fatal("Failed to resolve external reference")
|
||||
}
|
||||
|
||||
if resolvedType, ok := resolved["type"].(string); !ok || resolvedType != "object" {
|
||||
t.Error("Expected Customer schema to be an object")
|
||||
}
|
||||
|
||||
if props, ok := resolved["properties"].(map[string]interface{}); ok {
|
||||
if _, hasCustomerId := props["customerId"]; !hasCustomerId {
|
||||
t.Error("Expected Customer schema to have 'customerId' property")
|
||||
}
|
||||
if _, hasContact := props["contactInfo"]; !hasContact {
|
||||
t.Error("Expected Customer schema to have 'contactInfo' property")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected Customer schema to have properties")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedExternalReferences(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldCache := externalRefCache
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
externalRefCache = oldCache
|
||||
}()
|
||||
|
||||
externalRefCache = make(map[string]map[string]interface{})
|
||||
|
||||
schemaPath := filepath.Join("..", "tests", "external_schemas.yaml")
|
||||
absPath, _ := filepath.Abs(schemaPath)
|
||||
|
||||
data, err := os.ReadFile(schemaPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test schema file not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
externalSpec := SafelyUnmarshalSpec(data)
|
||||
if externalSpec == nil {
|
||||
t.Fatal("Failed to unmarshal external schema")
|
||||
}
|
||||
|
||||
externalRefCache[absPath] = externalSpec
|
||||
|
||||
customerSchema, ok := externalSpec["components"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected components in external schema")
|
||||
}
|
||||
|
||||
schemas, ok := customerSchema["schemas"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected schemas in components")
|
||||
}
|
||||
|
||||
customer, ok := schemas["Customer"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected Customer definition")
|
||||
}
|
||||
|
||||
visited := make(map[string]bool)
|
||||
expanded := ExpandSchema(externalSpec, customer, visited, externalSpec)
|
||||
|
||||
if expanded == nil {
|
||||
t.Fatal("Failed to expand Customer schema")
|
||||
}
|
||||
|
||||
if contactInfo, ok := expanded.Properties["contactInfo"]; ok {
|
||||
if len(contactInfo.Properties) == 0 {
|
||||
t.Error("Expected contactInfo to have expanded properties (email, phone)")
|
||||
}
|
||||
} else {
|
||||
t.Error("Expected Customer to have contactInfo property")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryObjectParameterHandling(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldCache := externalRefCache
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
externalRefCache = oldCache
|
||||
}()
|
||||
|
||||
externalRefCache = make(map[string]map[string]interface{})
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_query_object.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected paths in spec")
|
||||
}
|
||||
|
||||
searchPath, ok := paths["/search"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected /search path")
|
||||
}
|
||||
|
||||
getOp, ok := searchPath["get"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected GET operation")
|
||||
}
|
||||
|
||||
params, ok := getOp["parameters"].([]interface{})
|
||||
if !ok || len(params) == 0 {
|
||||
t.Fatal("Expected parameters in GET /search")
|
||||
}
|
||||
|
||||
param0, ok := params[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected first parameter to be a map")
|
||||
}
|
||||
|
||||
if in, ok := param0["in"].(string); !ok || in != "query" {
|
||||
t.Error("Expected parameter to be in query")
|
||||
}
|
||||
|
||||
if schema, ok := param0["schema"].(map[string]interface{}); !ok {
|
||||
t.Error("Expected parameter to have schema")
|
||||
} else {
|
||||
if schemaType, ok := schema["type"].(string); !ok || schemaType != "object" {
|
||||
t.Error("Expected parameter schema to be object type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBodyContextPreservation(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldCache := externalRefCache
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
externalRefCache = oldCache
|
||||
}()
|
||||
|
||||
externalRefCache = make(map[string]map[string]interface{})
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v3.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected paths in spec")
|
||||
}
|
||||
|
||||
foundTest := false
|
||||
for _, pathItem := range paths {
|
||||
if pathMap, ok := pathItem.(map[string]interface{}); ok {
|
||||
for method, op := range pathMap {
|
||||
if strings.ToLower(method) == "post" || strings.ToLower(method) == "put" {
|
||||
if opMap, ok := op.(map[string]interface{}); ok {
|
||||
if reqBody, ok := opMap["requestBody"].(map[string]interface{}); ok {
|
||||
if ref, hasRef := reqBody["$ref"].(string); hasRef {
|
||||
resolved, contextSpec := ResolveRefWithContext(spec, ref)
|
||||
if resolved == nil {
|
||||
t.Errorf("Failed to resolve requestBody ref: %s", ref)
|
||||
}
|
||||
if contextSpec == nil {
|
||||
t.Error("Expected context spec to be returned")
|
||||
}
|
||||
foundTest = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundTest {
|
||||
t.Skip("No requestBody refs found in spec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultValueHandling(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
}()
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v2.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected paths in spec")
|
||||
}
|
||||
|
||||
usersPath, ok := paths["/users"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Skip("No /users path in spec")
|
||||
}
|
||||
|
||||
getOp, ok := usersPath["get"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Skip("No GET operation on /users")
|
||||
}
|
||||
|
||||
params, ok := getOp["parameters"].([]interface{})
|
||||
if !ok {
|
||||
t.Skip("No parameters on GET /users")
|
||||
}
|
||||
|
||||
foundDefault := false
|
||||
for _, p := range params {
|
||||
if pMap, ok := p.(map[string]interface{}); ok {
|
||||
if defaultVal := pMap["default"]; defaultVal != nil {
|
||||
foundDefault = true
|
||||
if name, ok := pMap["name"].(string); ok {
|
||||
if name == "limit" {
|
||||
if defaultInt, ok := defaultVal.(int); ok && defaultInt != 10 {
|
||||
t.Errorf("Expected default value of 10 for limit, got %d", defaultInt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDefault {
|
||||
t.Skip("No parameters with default values found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONCurlQuoting(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldSwaggerURL := swaggerURL
|
||||
oldAPITarget := apiTarget
|
||||
oldBasePath := basePath
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
swaggerURL = oldSwaggerURL
|
||||
apiTarget = oldAPITarget
|
||||
basePath = oldBasePath
|
||||
}()
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v3.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
// Set up for local file mode
|
||||
swaggerURL = ""
|
||||
apiTarget = ""
|
||||
basePath = ""
|
||||
|
||||
// Simulate GenerateRequests parsing
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
apiTarget = serverURL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find a POST endpoint with JSON request body
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected paths in spec")
|
||||
}
|
||||
|
||||
foundJSONPost := false
|
||||
var testCurl string
|
||||
for pathName, pathItem := range paths {
|
||||
if pathMap, ok := pathItem.(map[string]interface{}); ok {
|
||||
if postOp, ok := pathMap["post"].(map[string]interface{}); ok {
|
||||
if reqBody, ok := postOp["requestBody"].(map[string]interface{}); ok {
|
||||
if content, ok := reqBody["content"].(map[string]interface{}); ok {
|
||||
if jsonContent, ok := content["application/json"].(map[string]interface{}); ok {
|
||||
if schema, ok := jsonContent["schema"].(map[string]interface{}); ok {
|
||||
// Generate a minimal curl command to verify quoting
|
||||
expanded := ExpandSchema(spec, schema, map[string]bool{}, spec)
|
||||
example := GenerateExample(expanded)
|
||||
bodyBytes, err := json.Marshal(example)
|
||||
if err == nil {
|
||||
// This simulates the curl generation logic
|
||||
testCurl = fmt.Sprintf("curl -X POST \"%s%s%s\" -H \"Content-Type: application/json\" -d '%s'",
|
||||
apiTarget, basePath, pathName, bodyBytes)
|
||||
foundJSONPost = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundJSONPost {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundJSONPost {
|
||||
t.Skip("No POST endpoint with JSON body found")
|
||||
}
|
||||
|
||||
// Verify the curl command has proper quoting
|
||||
// Should end with -d '...' NOT -d '...'"
|
||||
if !strings.Contains(testCurl, "-d '") {
|
||||
t.Error("Expected -d ' in curl command")
|
||||
}
|
||||
|
||||
// Check that it doesn't have the trailing quote bug: -d '%s'"
|
||||
if strings.Contains(testCurl, "'\"") {
|
||||
t.Error("Found trailing quote bug: curl has '\" which indicates malformed quoting")
|
||||
}
|
||||
|
||||
// Verify it ends with a single quote after the JSON data
|
||||
if !strings.HasSuffix(testCurl, "'}") && !strings.HasSuffix(testCurl, "']") && !strings.HasSuffix(testCurl, "'") {
|
||||
t.Errorf("Curl command should end with properly closed JSON in single quotes, got: %s", testCurl[len(testCurl)-20:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeServerURLHandling(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldSwaggerURL := swaggerURL
|
||||
oldAPITarget := apiTarget
|
||||
oldBasePath := basePath
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
swaggerURL = oldSwaggerURL
|
||||
apiTarget = oldAPITarget
|
||||
basePath = oldBasePath
|
||||
}()
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_relative_server.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
// Test 1: Verify that when -T is used with a spec that has a relative URL,
|
||||
// the basePath from the spec is preserved
|
||||
swaggerURL = ""
|
||||
apiTarget = "https://example.com" // Simulating -T flag
|
||||
basePath = ""
|
||||
|
||||
// Parse server info from spec (simulating what GenerateRequests does)
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if !strings.Contains(serverURL, "://") && serverURL != "/" {
|
||||
// Relative URL - should become basePath
|
||||
basePath = serverURL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify basePath was extracted even though -T was used
|
||||
if basePath != "/api/v1" {
|
||||
t.Errorf("Expected basePath to be '/api/v1' from spec even with -T flag, got: '%s'", basePath)
|
||||
}
|
||||
|
||||
// Verify apiTarget was not overwritten by spec (since -T was used)
|
||||
if apiTarget != "https://example.com" {
|
||||
t.Errorf("Expected apiTarget to remain 'https://example.com' from -T flag, got: '%s'", apiTarget)
|
||||
}
|
||||
|
||||
// Test 2: Verify that endpoints command can work even without apiTarget
|
||||
// Reset for this test
|
||||
apiTarget = ""
|
||||
basePath = ""
|
||||
swaggerURL = ""
|
||||
|
||||
// When apiTarget is empty and we have relative URL, it should still extract basePath
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if !strings.Contains(serverURL, "://") && serverURL != "/" {
|
||||
basePath = normalizeBasePath(serverURL)
|
||||
// For endpoints command, we don't need apiTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if basePath != "/api/v1" {
|
||||
t.Errorf("Expected basePath to be extracted for endpoints command, got: '%s'", basePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetFlagPreservesSpecBasePath(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldSwaggerURL := swaggerURL
|
||||
oldAPITarget := apiTarget
|
||||
oldBasePath := basePath
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
swaggerURL = oldSwaggerURL
|
||||
apiTarget = oldAPITarget
|
||||
basePath = oldBasePath
|
||||
}()
|
||||
|
||||
// Test with Swagger v2 spec
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v2.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
// Simulate -T flag being used
|
||||
swaggerURL = ""
|
||||
apiTarget = "https://staging.example.com" // User provided target
|
||||
basePath = ""
|
||||
|
||||
// Parse basePath from spec (should happen even with -T)
|
||||
if v, ok := spec["swagger"].(string); ok && strings.HasPrefix(v, "2") {
|
||||
if bp, ok := spec["basePath"].(string); ok && bp != "/" && bp != "" {
|
||||
basePath = bp
|
||||
}
|
||||
}
|
||||
|
||||
// Verify basePath was extracted
|
||||
if basePath != "/v1" {
|
||||
t.Errorf("Expected basePath '/v1' to be preserved from spec even with -T flag, got: '%s'", basePath)
|
||||
}
|
||||
|
||||
// Verify full constructed path would be correct
|
||||
fullPath := apiTarget + basePath + "/users"
|
||||
expectedPath := "https://staging.example.com/v1/users"
|
||||
if fullPath != expectedPath {
|
||||
t.Errorf("Expected full path '%s', got '%s'", expectedPath, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIv3AbsoluteServerURLWithTargetFlag(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldSwaggerURL := swaggerURL
|
||||
oldAPITarget := apiTarget
|
||||
oldBasePath := basePath
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
swaggerURL = oldSwaggerURL
|
||||
apiTarget = oldAPITarget
|
||||
basePath = oldBasePath
|
||||
}()
|
||||
|
||||
// Test with OpenAPI v3 spec that has absolute server URL with path
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v3.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
// Simulate -T flag being used (user wants to override host)
|
||||
swaggerURL = ""
|
||||
apiTarget = "https://staging.example.com" // User provided target
|
||||
basePath = ""
|
||||
|
||||
// Parse server info from spec (simulating what GenerateRequests does)
|
||||
if v, ok := spec["openapi"].(string); ok && strings.HasPrefix(v, "3") {
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
// This is an absolute URL
|
||||
// When -T is set, we should still extract the path
|
||||
if apiTarget != "" {
|
||||
// Parse the server URL to extract path
|
||||
if parsedURL, err := url.Parse(serverURL); err == nil && parsedURL.Path != "" && parsedURL.Path != "/" {
|
||||
basePath = normalizeBasePath(parsedURL.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify basePath was extracted from the absolute server URL
|
||||
if basePath != "/v1" {
|
||||
t.Errorf("Expected basePath '/v1' to be extracted from absolute server URL even with -T flag, got: '%s'", basePath)
|
||||
}
|
||||
|
||||
// Verify apiTarget was not overwritten (should still be from -T flag)
|
||||
if apiTarget != "https://staging.example.com" {
|
||||
t.Errorf("Expected apiTarget to remain 'https://staging.example.com' from -T flag, got: '%s'", apiTarget)
|
||||
}
|
||||
|
||||
// Verify full constructed path would be correct
|
||||
fullPath := apiTarget + basePath + "/users"
|
||||
expectedPath := "https://staging.example.com/v1/users"
|
||||
if fullPath != expectedPath {
|
||||
t.Errorf("Expected full path '%s', got '%s'", expectedPath, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPIv3AbsoluteServerURLWithoutTargetFlag(t *testing.T) {
|
||||
oldSpecBaseDir := specBaseDir
|
||||
oldSwaggerURL := swaggerURL
|
||||
oldAPITarget := apiTarget
|
||||
oldBasePath := basePath
|
||||
defer func() {
|
||||
specBaseDir = oldSpecBaseDir
|
||||
swaggerURL = oldSwaggerURL
|
||||
apiTarget = oldAPITarget
|
||||
basePath = oldBasePath
|
||||
}()
|
||||
|
||||
specPath := filepath.Join("..", "tests", "test_spec_v3.yaml")
|
||||
absPath, _ := filepath.Abs(specPath)
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Skipf("Test spec not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
spec := SafelyUnmarshalSpec(data)
|
||||
if spec == nil {
|
||||
t.Fatal("Failed to unmarshal spec")
|
||||
}
|
||||
|
||||
swaggerURL = ""
|
||||
apiTarget = ""
|
||||
basePath = ""
|
||||
|
||||
if v, ok := spec["openapi"].(string); ok && strings.HasPrefix(v, "3") {
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok && strings.Contains(serverURL, "://") {
|
||||
if parsedURL, err := url.Parse(serverURL); err == nil {
|
||||
basePath = normalizeBasePath(parsedURL.Path)
|
||||
if apiTarget == "" {
|
||||
apiTarget = parsedURL.Scheme + "://" + parsedURL.Host
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apiTarget != "https://api.example.com" {
|
||||
t.Errorf("Expected apiTarget 'https://api.example.com', got '%s'", apiTarget)
|
||||
}
|
||||
if basePath != "/v1" {
|
||||
t.Errorf("Expected basePath '/v1', got '%s'", basePath)
|
||||
}
|
||||
if endpointPath := basePath + "/users"; endpointPath != "/v1/users" {
|
||||
t.Errorf("Expected endpoints path '/v1/users', got '%s'", endpointPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBasePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{in: "", want: ""},
|
||||
{in: "/", want: ""},
|
||||
{in: " /v1/ ", want: "/v1"},
|
||||
{in: "/v1/", want: "/v1"},
|
||||
{in: "/v1", want: "/v1"},
|
||||
{in: "v1/", want: "/v1"},
|
||||
{in: "v1", want: "/v1"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := normalizeBasePath(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("normalizeBasePath(%q): expected %q, got %q", tc.in, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Shared Schemas
|
||||
version: 1.0.0
|
||||
components:
|
||||
schemas:
|
||||
Customer:
|
||||
type: object
|
||||
required:
|
||||
- customerId
|
||||
- companyName
|
||||
properties:
|
||||
customerId:
|
||||
type: string
|
||||
example: "CUST-12345"
|
||||
companyName:
|
||||
type: string
|
||||
example: "Acme Corporation"
|
||||
contactInfo:
|
||||
$ref: '#/components/schemas/ContactInfo'
|
||||
billingAddress:
|
||||
$ref: '#/components/schemas/Address'
|
||||
ContactInfo:
|
||||
type: object
|
||||
properties:
|
||||
phone:
|
||||
type: string
|
||||
example: "+1-555-0123"
|
||||
email:
|
||||
type: string
|
||||
example: "[email protected]"
|
||||
Address:
|
||||
type: object
|
||||
properties:
|
||||
street:
|
||||
type: string
|
||||
example: "123 Main St"
|
||||
city:
|
||||
type: string
|
||||
example: "Springfield"
|
||||
state:
|
||||
type: string
|
||||
example: "IL"
|
||||
postalCode:
|
||||
type: string
|
||||
example: "62701"
|
||||
@@ -0,0 +1,13 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Schema A
|
||||
version: 1.0.0
|
||||
components:
|
||||
schemas:
|
||||
SchemaA:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
nested:
|
||||
$ref: 'schema_b.yaml#/components/schemas/SchemaB'
|
||||
@@ -0,0 +1,12 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Schema B
|
||||
version: 1.0.0
|
||||
components:
|
||||
schemas:
|
||||
SchemaB:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
example: "nested-value"
|
||||
@@ -0,0 +1,17 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Main spec for nested external refs test
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://api.example.com
|
||||
paths:
|
||||
/test:
|
||||
post:
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: 'subdir/schema_a.yaml#/components/schemas/SchemaA'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
@@ -0,0 +1,23 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Query Object Test
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://api.example.com
|
||||
paths:
|
||||
/search:
|
||||
get:
|
||||
summary: Search with object filter
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
age:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
@@ -0,0 +1,12 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Relative Server URL Test
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
paths:
|
||||
/test:
|
||||
get:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
@@ -0,0 +1,30 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Main API Spec
|
||||
version: 1.0.0
|
||||
description: Test spec with external file references
|
||||
servers:
|
||||
- url: https://api.example.com/v2
|
||||
paths:
|
||||
/customers:
|
||||
get:
|
||||
summary: Get customers
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: './external_schemas.yaml#/components/schemas/Customer'
|
||||
post:
|
||||
summary: Create customer
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './external_schemas.yaml#/components/schemas/Customer'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
@@ -0,0 +1,124 @@
|
||||
swagger: "2.0"
|
||||
info:
|
||||
title: Swagger v2 Test API
|
||||
version: 1.0.0
|
||||
description: Test spec for validating Swagger 2.0 reference resolution
|
||||
host: api.example.com
|
||||
basePath: /v1
|
||||
schemes:
|
||||
- https
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: Get all users
|
||||
parameters:
|
||||
- $ref: '#/parameters/LimitParam'
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/User'
|
||||
post:
|
||||
summary: Create user
|
||||
parameters:
|
||||
- name: user
|
||||
in: body
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/products/{id}:
|
||||
get:
|
||||
summary: Get product
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
schema:
|
||||
$ref: '#/definitions/Product'
|
||||
/orders:
|
||||
post:
|
||||
summary: Create order
|
||||
parameters:
|
||||
- name: order
|
||||
in: body
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/definitions/OrderItem'
|
||||
total:
|
||||
type: number
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
parameters:
|
||||
LimitParam:
|
||||
name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 10
|
||||
definitions:
|
||||
User:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 42
|
||||
name:
|
||||
type: string
|
||||
example: "Jane Smith"
|
||||
email:
|
||||
type: string
|
||||
example: "[email protected]"
|
||||
address:
|
||||
$ref: '#/definitions/Address'
|
||||
Address:
|
||||
type: object
|
||||
properties:
|
||||
street:
|
||||
type: string
|
||||
city:
|
||||
type: string
|
||||
zipCode:
|
||||
type: string
|
||||
example: "12345"
|
||||
Product:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
example: 29.99
|
||||
category:
|
||||
type: string
|
||||
enum:
|
||||
- electronics
|
||||
- clothing
|
||||
- books
|
||||
OrderItem:
|
||||
type: object
|
||||
properties:
|
||||
productId:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
example: 2
|
||||
@@ -0,0 +1,159 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Test API with References
|
||||
version: 1.0.0
|
||||
description: Test spec for validating reference resolution improvements
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: Get all users
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/LimitParam'
|
||||
- name: offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/User'
|
||||
post:
|
||||
summary: Create user
|
||||
requestBody:
|
||||
$ref: '#/components/requestBodies/UserBody'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/products/{id}:
|
||||
get:
|
||||
summary: Get product by ID
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Product'
|
||||
/pets:
|
||||
post:
|
||||
summary: Create a pet
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Cat'
|
||||
- $ref: '#/components/schemas/Dog'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
/config:
|
||||
post:
|
||||
summary: Update config
|
||||
requestBody:
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Config'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated
|
||||
components:
|
||||
parameters:
|
||||
LimitParam:
|
||||
name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
example: 10
|
||||
requestBodies:
|
||||
UserBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- email
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 42
|
||||
name:
|
||||
type: string
|
||||
example: "John Doe"
|
||||
email:
|
||||
type: string
|
||||
example: "[email protected]"
|
||||
address:
|
||||
$ref: '#/components/schemas/Address'
|
||||
preferences:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
Address:
|
||||
type: object
|
||||
properties:
|
||||
street:
|
||||
type: string
|
||||
city:
|
||||
type: string
|
||||
country:
|
||||
type: string
|
||||
enum:
|
||||
- USA
|
||||
- Canada
|
||||
- UK
|
||||
Product:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
price:
|
||||
type: number
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
Cat:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [cat]
|
||||
meow:
|
||||
type: boolean
|
||||
Dog:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [dog]
|
||||
bark:
|
||||
type: boolean
|
||||
Config:
|
||||
type: object
|
||||
properties:
|
||||
theme:
|
||||
type: string
|
||||
example: "dark"
|
||||
notifications:
|
||||
type: boolean
|
||||
Reference in new issue
Block a user