Compare commits

...
20 Commits
Author SHA1 Message Date
Tony West 7ef8abdb8e Incremented version string 2026-05-13 14:47:17 -04:00
Tony West f1f9e19422 Improved detection for JS specs wrapped in swaggerDoc/spec/openapi 2026-05-13 14:46:47 -04:00
Tony West 0d6776b758 Incremented version string 2026-03-24 16:00:16 -04:00
Tony West 8f4e4a5d9f Fixes #55 2026-03-24 16:00:07 -04:00
Tony West 55baa79785 Incremented version string 2026-03-20 11:37:19 -04:00
Tony West 5bdafb64d8 Merge pull request #54 from bsysop/main
Adds new features from pull request
2026-03-20 11:35:58 -04:00
Guillermo Gregorio 27b59354fc Adds --replay-proxy flag to replay matched requests through a separate proxy 2026-03-19 22:04:42 -03:00
Guillermo Gregorio bee5088fcf Adds --force flag to bypass dangerous keyword checks 2026-03-13 14:56:15 -03:00
Tony West 1eda960182 Fixed bug where custom headers were not sent with the brute command 2026-03-13 00:13:54 -04:00
Tony West e805a7d190 Incremented version string 2026-02-27 22:11:10 -05:00
Tony West 18b20ad476 Merge pull request #52 from aringo-bf/feature/ref-resolution-hardening
Added several features/tests/bug fixes.
2026-02-27 22:10:01 -05:00
Tony West 5115b3cba3 Apply suggestions from code review 2026-02-27 22:06:13 -05:00
aringo-bf 942ad84c54 fix: reconcile merged ref-hardening changes with current logging helpers 2026-02-27 16:06:56 -06:00
aringo-bf 878f7dcd39 fix: normalize server base paths for v3 parsing 2026-02-27 16:05:18 -06:00
aringo-bf 275f426fc2 fix: extract path from absolute server URLs when -T flag is used
High priority fix:
- OpenAPI v3 specs with absolute server URLs (e.g., https://api.example.com/v1)
  now preserve the path component (/v1) when -T flag overrides the host
- Parse absolute server URL to extract path and set as basePath when apiTarget
  is already set by -T flag
- Impact: -T https://staging.example.com with spec servers[0].url =
  https://api.example.com/v1 now produces https://staging.example.com/v1/users
  (was https://staging.example.com/users)

Test coverage:
- Added TestOpenAPIv3AbsoluteServerURLWithTargetFlag to verify path extraction
- Test validates both basePath extraction and apiTarget preservation
- All 10 tests pass (9 PASS, 1 SKIP)
2026-02-27 16:04:42 -06:00
aringo-bf 6d027a66ce fix: preserve basePath with -T flag and allow endpoints with relative URLs
1. -T flag now preserves server path prefixes from spec
2. endpoints command works with relative server URLs
3. Enhanced tests to assert actual runtime behavior
2026-02-27 16:04:02 -06:00
aringo-bf 703039dc75 fix: JSON curl quoting bug and relative server URL handling
High priority fix:
1. Remove trailing double-quote from JSON curl generation
   - Changed -d '%s'" to -d '%s' on line 216
   - Fixes shell syntax errors in prepare output
   - Fixes malformed POST bodies in automate (was stripping " but leaving ')
   - Impact: JSON POST requests now valid shell and valid HTTP bodies

Medium priority fix:
2. Handle relative server URLs in local file mode
   - Detect when spec has relative URL (e.g., /api/v1) and no base available
   - Fail fast with clear error: "Use -T to specify target server"
   - Prevents silent generation of invalid URLs (://...)
   - Impact: Clear user guidance instead of skipped endpoints

Testing:
- Verified JSON curl has proper quoting: -d '{"key":"val"}'
- Verified bash syntax validation passes
- Added TestJSONCurlQuoting and TestRelativeServerURLHandling
- All 8 tests pass (7 PASS, 1 SKIP)
- Added test_relative_server.yaml fixture
2026-02-27 16:03:01 -06:00
aringo-bf e2a94378bf fix: resolve 4 critical reference resolution bugs
1. External parameter/requestBody refs preserve context for nested refs
2. Nested external refs resolve relative to parent file directory
3. Swagger v2 local files use proper URL schemes from spec
4. Added comprehensive Go test suite with 6 test functions
2026-02-27 16:02:55 -06:00
aringo-bf c9fa4f1257 fix: resolve all 5 auditor-reported bugs
Fixed external refs, nested refs, query object params, extra body fields, and default values
2026-02-27 16:02:51 -06:00
aringo-bf 90f0870bfb feat: comprehensive spec reference resolution improvements
Enhanced ResolveRef for Swagger v2 and OpenAPI v3, added external file references, implemented oneOf/anyOf/additionalProperties, refactored parameter handling, added test suite
2026-02-27 16:02:47 -06:00
20 changed files with 1715 additions and 160 deletions

No files matched your search

+13
View File
@@ -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.
-1
View File
@@ -4,7 +4,6 @@ import (
"encoding/base64"
"fmt"
"strings"
)
var (
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -29,7 +29,7 @@ var convertCmd = &cobra.Command{
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.1",
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
+391 -132
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
@@ -19,18 +20,24 @@ 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.")
@@ -79,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)
}
}
}
}
@@ -179,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 == "" {
@@ -189,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" {
@@ -204,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)
@@ -249,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 {
@@ -259,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":
@@ -324,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"}
@@ -335,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{
@@ -351,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{}{}
@@ -394,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 {
@@ -411,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)
@@ -423,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)
}
}
}
}
}
@@ -480,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" {
@@ -489,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:], "/")
@@ -503,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{}) {
+723
View File
@@ -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)
}
}
}
+46
View File
@@ -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"
+13
View File
@@ -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'
+12
View File
@@ -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"
+17
View File
@@ -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
+23
View File
@@ -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
+12
View File
@@ -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
+30
View File
@@ -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
+124
View File
@@ -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
+159
View File
@@ -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