diff --git a/packages/client/src/widgets/import/index.tsx b/packages/client/src/widgets/import/index.tsx
index 46766cd5..45be1097 100644
--- a/packages/client/src/widgets/import/index.tsx
+++ b/packages/client/src/widgets/import/index.tsx
@@ -151,14 +151,14 @@ const InitialDialog = ({ setModal, successAction }: InitialDialogProps) => {
`}
>
- Import Postman or HAR files
+ Import Postman, HAR, Swagger or OpenAPI files, or paste a URL
diff --git a/packages/server/internal/api/rimportv2/format_detection.go b/packages/server/internal/api/rimportv2/format_detection.go
index a6beb547..6f1bd48f 100644
--- a/packages/server/internal/api/rimportv2/format_detection.go
+++ b/packages/server/internal/api/rimportv2/format_detection.go
@@ -25,6 +25,7 @@ const (
FormatJSON
FormatCURL
FormatPostman
+ FormatOpenAPI
)
const ReasonValidJSON = "Valid JSON; "
@@ -42,6 +43,8 @@ func (f Format) String() string {
return "CURL"
case FormatPostman:
return "Postman"
+ case FormatOpenAPI:
+ return "OpenAPI"
default:
return "Unknown"
}
@@ -57,19 +60,27 @@ type DetectionResult struct {
// FormatDetector implements automatic format detection with confidence scoring
type FormatDetector struct {
// Pre-compiled regular expressions for performance
- harPattern *regexp.Regexp
- curlPattern *regexp.Regexp
- postmanPattern *regexp.Regexp
- yamlPattern *regexp.Regexp
+ harPattern *regexp.Regexp
+ curlPattern *regexp.Regexp
+ postmanPattern *regexp.Regexp
+ yamlPattern *regexp.Regexp
+ swaggerPattern *regexp.Regexp
+ openapi3Pattern *regexp.Regexp
+ yamlSwaggerPat *regexp.Regexp
+ yamlOpenapi3Pat *regexp.Regexp
}
// NewFormatDetector creates a new format detector with compiled patterns
func NewFormatDetector() *FormatDetector {
return &FormatDetector{
- harPattern: regexp.MustCompile(`^\s*\{?\s*"?log"?[\s\S]*"?entries"?[\s\S]*\}?\s*$`),
- curlPattern: regexp.MustCompile(`(?i)^\s*curl\s+`),
- postmanPattern: regexp.MustCompile(`(?i)"?info"?\s*:\s*\{[\s\S]*"?name"?[\s\S]*"?schema"?\s*:\s*"https://schema\.getpostman\.com/json/collection/v2\.1\.0/collection\.json"`),
- yamlPattern: regexp.MustCompile(`(?i)^\s*flows?\s*:`),
+ harPattern: regexp.MustCompile(`^\s*\{?\s*"?log"?[\s\S]*"?entries"?[\s\S]*\}?\s*$`),
+ curlPattern: regexp.MustCompile(`(?i)^\s*curl\s+`),
+ postmanPattern: regexp.MustCompile(`(?i)"?info"?\s*:\s*\{[\s\S]*"?name"?[\s\S]*"?schema"?\s*:\s*"https://schema\.getpostman\.com/json/collection/v2\.1\.0/collection\.json"`),
+ yamlPattern: regexp.MustCompile(`(?i)^\s*flows?\s*:`),
+ swaggerPattern: regexp.MustCompile(`(?i)"swagger"\s*:\s*"2\.\d+"`),
+ openapi3Pattern: regexp.MustCompile(`(?i)"openapi"\s*:\s*"3\.\d+\.\d+"`),
+ yamlSwaggerPat: regexp.MustCompile(`(?im)^swagger\s*:\s*["']?2\.\d+`),
+ yamlOpenapi3Pat: regexp.MustCompile(`(?im)^openapi\s*:\s*["']?3\.\d+`),
}
}
@@ -91,6 +102,7 @@ func (fd *FormatDetector) DetectFormat(data []byte) *DetectionResult {
results := []*DetectionResult{
fd.detectHAR(trimmed),
fd.detectPostman(trimmed),
+ fd.detectOpenAPI(trimmed),
fd.detectCURL(trimmed),
fd.detectYAML(trimmed),
fd.detectJSON(trimmed),
@@ -403,6 +415,93 @@ func (fd *FormatDetector) detectYAML(content string) *DetectionResult {
}
}
+// detectOpenAPI detects OpenAPI/Swagger spec format with confidence scoring.
+// Supports both Swagger 2.0 ("swagger": "2.0") and OpenAPI 3.x ("openapi": "3.x.x").
+func (fd *FormatDetector) detectOpenAPI(content string) *DetectionResult {
+ confidence := 0.0
+ reason := ""
+
+ // Check for Swagger 2.0 pattern (JSON)
+ if fd.swaggerPattern.MatchString(content) {
+ confidence += 0.9
+ reason += "Swagger 2.0 spec detected; "
+ }
+
+ // Check for OpenAPI 3.x pattern (JSON)
+ if fd.openapi3Pattern.MatchString(content) {
+ confidence += 0.9
+ reason += "OpenAPI 3.x spec detected; "
+ }
+
+ // Check for YAML-format Swagger 2.0
+ if fd.yamlSwaggerPat.MatchString(content) {
+ confidence += 0.9
+ reason += "Swagger 2.0 YAML spec detected; "
+ }
+
+ // Check for YAML-format OpenAPI 3.x
+ if fd.yamlOpenapi3Pat.MatchString(content) {
+ confidence += 0.9
+ reason += "OpenAPI 3.x YAML spec detected; "
+ }
+
+ // Look for paths field (key indicator of OpenAPI/Swagger)
+ if strings.Contains(content, `"paths"`) || strings.Contains(content, "paths:") {
+ confidence += 0.2
+ reason += "paths field found; "
+ }
+
+ // Look for info field
+ if strings.Contains(content, `"info"`) || strings.Contains(content, "info:") {
+ confidence += 0.1
+ reason += "info field found; "
+ }
+
+ // Validate it's valid JSON or YAML
+ var jsonData map[string]interface{}
+ if err := json.Unmarshal([]byte(content), &jsonData); err == nil {
+ confidence += 0.1
+ reason += ReasonValidJSON
+ } else {
+ // Try YAML
+ var yamlData map[string]interface{}
+ if err := yaml.Unmarshal([]byte(content), &yamlData); err == nil {
+ confidence += 0.1
+ reason += "Valid YAML; "
+ }
+ }
+
+ if confidence < 0 {
+ confidence = 0
+ }
+
+ return &DetectionResult{
+ Format: FormatOpenAPI,
+ Confidence: confidence,
+ Reason: strings.TrimSpace(reason),
+ }
+}
+
+// validateOpenAPI validates OpenAPI/Swagger format specifically
+func (fd *FormatDetector) validateOpenAPI(data []byte) error {
+ // Try JSON first, then YAML
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ if err := yaml.Unmarshal(data, &raw); err != nil {
+ return fmt.Errorf("invalid OpenAPI spec: not valid JSON or YAML: %w", err)
+ }
+ }
+
+ // Must have either "swagger" or "openapi" field
+ _, hasSwagger := raw["swagger"]
+ _, hasOpenAPI := raw["openapi"]
+ if !hasSwagger && !hasOpenAPI {
+ return fmt.Errorf("missing 'swagger' or 'openapi' version field")
+ }
+
+ return nil
+}
+
// detectJSON detects generic JSON format with confidence scoring
func (fd *FormatDetector) detectJSON(content string) *DetectionResult {
confidence := 0.0
@@ -486,6 +585,8 @@ func (fd *FormatDetector) ValidateFormat(data []byte, format Format) error {
return fd.validateYAML(data)
case FormatJSON:
return fd.validateJSON(data)
+ case FormatOpenAPI:
+ return fd.validateOpenAPI(data)
default:
return fmt.Errorf("unknown format: %v", format)
}
diff --git a/packages/server/internal/api/rimportv2/service.go b/packages/server/internal/api/rimportv2/service.go
index d4345126..f62cd9c6 100644
--- a/packages/server/internal/api/rimportv2/service.go
+++ b/packages/server/internal/api/rimportv2/service.go
@@ -125,7 +125,7 @@ type ImportConstraints struct {
func DefaultConstraints() *ImportConstraints {
return &ImportConstraints{
MaxDataSizeBytes: 50 * 1024 * 1024, // 50MB
- SupportedFormats: []Format{FormatHAR, FormatYAML, FormatJSON, FormatCURL, FormatPostman},
+ SupportedFormats: []Format{FormatHAR, FormatYAML, FormatJSON, FormatCURL, FormatPostman, FormatOpenAPI},
AllowedMimeTypes: []string{
"application/json",
"application/har",
@@ -218,6 +218,13 @@ func WithLogger(logger *slog.Logger) ServiceOption {
}
}
+// WithURLFetcher sets a custom URL fetcher (useful for testing)
+func WithURLFetcher(fetcher URLFetcher) ServiceOption {
+ return func(s *Service) {
+ s.urlFetcher = fetcher
+ }
+}
+
// WithHTTPService sets the HTTP service for the service (required for HAR import overwrite detection)
func WithHTTPService(httpService *shttp.HTTPService) ServiceOption {
return func(s *Service) {
@@ -231,6 +238,7 @@ type Service struct {
importer Importer
validator Validator
translatorRegistry *TranslatorRegistry
+ urlFetcher URLFetcher
logger *slog.Logger
timeout time.Duration
}
@@ -244,6 +252,7 @@ func NewService(importer Importer, validator Validator, opts ...ServiceOption) *
importer: importer,
validator: validator,
translatorRegistry: NewTranslatorRegistry(nil), // Auto-initialize translator registry without HTTP service (will be overridden if provided)
+ urlFetcher: NewURLFetcher(),
timeout: 30 * time.Minute, // Default timeout for import processing
logger: slog.Default(), // Default logger
}
@@ -461,7 +470,11 @@ func (s *Service) ImportWithTextData(ctx context.Context, req *ImportRequest) (*
return s.Import(ctx, req)
}
-// ImportUnified processes any supported format with automatic detection
+// ImportUnified processes any supported format with automatic detection.
+// It handles three input modes:
+// 1. Binary data (req.Data) - file uploads (Postman JSON, HAR, etc.)
+// 2. Text data (req.TextData) - pasted curl commands, raw JSON/YAML
+// 3. URL text (req.TextData is a URL) - fetches content from URL (e.g., swagger.json URL)
func (s *Service) ImportUnified(ctx context.Context, req *ImportRequest) (*ImportResults, error) {
s.logger.Debug("ImportUnified: Starting", "workspace_id", req.WorkspaceID)
// Check if context is already cancelled
@@ -490,6 +503,11 @@ func (s *Service) ImportUnified(ctx context.Context, req *ImportRequest) (*Impor
return nil, err // Return the original error for workspace access issues
}
+ // Resolve input data: handle TextData and URL fetching
+ if err := s.resolveInputData(ctx, req); err != nil {
+ return nil, err
+ }
+
s.logger.Debug("ImportUnified: Translating data")
// Detect format and translate data
translationResult, err := s.importer.ImportAndStoreUnified(ctx, req.Data, req.WorkspaceID)
@@ -701,6 +719,50 @@ func (s *Service) ImportUnifiedWithTextData(ctx context.Context, req *ImportRequ
return s.ImportUnified(ctx, req)
}
+// resolveInputData ensures req.Data is populated for format detection.
+// It handles three cases:
+// 1. Data already present (file upload) - no action needed
+// 2. TextData is a URL - fetch content from the URL
+// 3. TextData is raw text (curl, JSON, YAML) - convert to bytes
+func (s *Service) resolveInputData(ctx context.Context, req *ImportRequest) error {
+ if len(req.Data) > 0 {
+ return nil // Already have binary data from file upload
+ }
+
+ if req.TextData == "" {
+ return nil // No text data either (validation should have caught this)
+ }
+
+ // Check if TextData is a URL that we should fetch content from
+ if IsURL(req.TextData) {
+ s.logger.Info("TextData is a URL, fetching content",
+ "url", req.TextData,
+ "workspace_id", req.WorkspaceID)
+
+ data, err := s.urlFetcher.Fetch(ctx, req.TextData)
+ if err != nil {
+ return fmt.Errorf("failed to fetch URL %q: %w", req.TextData, err)
+ }
+
+ req.Data = data
+ if req.Name == "" {
+ req.Name = req.TextData
+ }
+ s.logger.Info("URL content fetched successfully",
+ "url", req.TextData,
+ "data_size", len(data))
+ return nil
+ }
+
+ // TextData is raw text (curl command, JSON, YAML, etc.)
+ req.Data = []byte(req.TextData)
+ s.logger.Debug("Converted text data to binary",
+ "workspace_id", req.WorkspaceID,
+ "data_size", len(req.Data))
+
+ return nil
+}
+
// DetectFormat performs format detection on the provided data
func (s *Service) DetectFormat(ctx context.Context, data []byte) (*DetectionResult, error) {
if len(data) == 0 {
diff --git a/packages/server/internal/api/rimportv2/translators.go b/packages/server/internal/api/rimportv2/translators.go
index 32ee69d4..8b033a19 100644
--- a/packages/server/internal/api/rimportv2/translators.go
+++ b/packages/server/internal/api/rimportv2/translators.go
@@ -18,6 +18,7 @@ import (
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp"
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/harv2"
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/tcurlv2"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/topenapiv2"
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/tpostmanv2"
yamlflowsimplev2 "github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/yamlflowsimplev2"
)
@@ -90,6 +91,7 @@ func NewTranslatorRegistry(httpService *shttp.HTTPService) *TranslatorRegistry {
registry.RegisterTranslator(NewYAMLTranslator())
registry.RegisterTranslator(NewCURLTranslator())
registry.RegisterTranslator(NewPostmanTranslator())
+ registry.RegisterTranslator(NewOpenAPITranslator())
registry.RegisterTranslator(NewJSONTranslator())
return registry
@@ -407,6 +409,56 @@ func (t *PostmanTranslator) Translate(ctx context.Context, data []byte, workspac
return result, nil
}
+// OpenAPITranslator implements Translator for OpenAPI/Swagger spec format
+type OpenAPITranslator struct {
+ detector *FormatDetector
+}
+
+// NewOpenAPITranslator creates a new OpenAPI translator
+func NewOpenAPITranslator() *OpenAPITranslator {
+ return &OpenAPITranslator{
+ detector: NewFormatDetector(),
+ }
+}
+
+func (t *OpenAPITranslator) GetFormat() Format {
+ return FormatOpenAPI
+}
+
+func (t *OpenAPITranslator) Validate(data []byte) error {
+ return t.detector.ValidateFormat(data, FormatOpenAPI)
+}
+
+func (t *OpenAPITranslator) Translate(ctx context.Context, data []byte, workspaceID idwrap.IDWrap) (*TranslationResult, error) {
+ opts := topenapiv2.ConvertOptions{
+ WorkspaceID: workspaceID,
+ }
+
+ resolved, err := topenapiv2.ConvertOpenAPI(data, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert OpenAPI spec: %w", err)
+ }
+
+ result := &TranslationResult{
+ HTTPRequests: resolved.HTTPRequests,
+ Files: resolved.Files,
+ Headers: resolved.Headers,
+ SearchParams: resolved.SearchParams,
+ BodyRaw: resolved.BodyRaw,
+ Asserts: resolved.Asserts,
+ Flows: []mflow.Flow{resolved.Flow},
+ Nodes: resolved.Nodes,
+ RequestNodes: resolved.RequestNodes,
+ Edges: resolved.Edges,
+ ProcessedAt: time.Now().UnixMilli(),
+ }
+
+ // Extract domains from HTTP requests
+ result.Domains = extractDomainsFromHTTP(result.HTTPRequests)
+
+ return result, nil
+}
+
// JSONTranslator implements Translator for generic JSON format
type JSONTranslator struct {
detector *FormatDetector
diff --git a/packages/server/internal/api/rimportv2/urlfetch.go b/packages/server/internal/api/rimportv2/urlfetch.go
new file mode 100644
index 00000000..0ce99f8a
--- /dev/null
+++ b/packages/server/internal/api/rimportv2/urlfetch.go
@@ -0,0 +1,91 @@
+package rimportv2
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// maxFetchSize is the maximum size of data fetched from a URL (50MB).
+const maxFetchSize = 50 * 1024 * 1024
+
+// URLFetcher fetches content from URLs. It's an interface to allow testing.
+type URLFetcher interface {
+ Fetch(ctx context.Context, rawURL string) ([]byte, error)
+}
+
+// DefaultURLFetcher implements URLFetcher using net/http.
+type DefaultURLFetcher struct {
+ client *http.Client
+}
+
+// NewURLFetcher creates a new DefaultURLFetcher.
+func NewURLFetcher() *DefaultURLFetcher {
+ return &DefaultURLFetcher{
+ client: &http.Client{},
+ }
+}
+
+// Fetch downloads content from the given URL.
+func (f *DefaultURLFetcher) Fetch(ctx context.Context, rawURL string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Set Accept header to prefer JSON/YAML
+ req.Header.Set("Accept", "application/json, application/yaml, text/yaml, */*")
+
+ resp, err := f.client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch URL: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("URL returned status %d", resp.StatusCode)
+ }
+
+ // Limit the response size
+ limitedReader := io.LimitReader(resp.Body, maxFetchSize+1)
+ data, err := io.ReadAll(limitedReader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ if len(data) > maxFetchSize {
+ return nil, fmt.Errorf("response exceeds maximum size of %d bytes", maxFetchSize)
+ }
+
+ return data, nil
+}
+
+// IsURL checks if a string looks like a fetchable HTTP(S) URL.
+// Returns false for curl commands and other non-URL text.
+func IsURL(s string) bool {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return false
+ }
+
+ // Must start with http:// or https://
+ if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
+ return false
+ }
+
+ // Should not contain spaces (URLs don't have spaces, curl commands do)
+ if strings.ContainsAny(s, " \t\n") {
+ return false
+ }
+
+ // Must be a valid URL
+ u, err := url.Parse(s)
+ if err != nil {
+ return false
+ }
+
+ // Must have a host
+ return u.Host != ""
+}
diff --git a/packages/server/pkg/translate/topenapiv2/real_world_test.go b/packages/server/pkg/translate/topenapiv2/real_world_test.go
new file mode 100644
index 00000000..4776752b
--- /dev/null
+++ b/packages/server/pkg/translate/topenapiv2/real_world_test.go
@@ -0,0 +1,618 @@
+package topenapiv2
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mfile"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mhttp"
+)
+
+// --- Real-world Swagger 2.0 (Petstore) ---
+
+func TestRealWorld_PetstoreSwagger2(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{
+ WorkspaceID: idwrap.NewNow(),
+ }
+
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Petstore has 14 operations across all paths
+ require.Equal(t, 14, len(resolved.HTTPRequests), "Should import all 14 operations")
+
+ // Flow
+ require.NotEmpty(t, resolved.Flow.ID, "Should generate a Flow ID")
+ require.Equal(t, "Petstore API", resolved.Flow.Name, "Flow should use spec title")
+
+ // Nodes: 1 start + 14 request
+ require.Equal(t, 15, len(resolved.Nodes), "Should have 15 nodes (1 start + 14 request)")
+ require.Equal(t, 14, len(resolved.RequestNodes), "Should have 14 request node metadata entries")
+ require.Equal(t, 14, len(resolved.Edges), "Should have 14 edges")
+
+ t.Logf("Imported Petstore Swagger 2.0:")
+ t.Logf(" - Requests: %d", len(resolved.HTTPRequests))
+ t.Logf(" - Flow Nodes: %d", len(resolved.Nodes))
+ t.Logf(" - Flow Edges: %d", len(resolved.Edges))
+ t.Logf(" - Files/Folders: %d", len(resolved.Files))
+ t.Logf(" - Headers: %d", len(resolved.Headers))
+ t.Logf(" - Query Params: %d", len(resolved.SearchParams))
+ t.Logf(" - Body Raw: %d", len(resolved.BodyRaw))
+
+ // Verify each request has a valid URL with the base URL prefix
+ for _, req := range resolved.HTTPRequests {
+ require.True(t, strings.HasPrefix(req.Url, "https://petstore.swagger.io/v2"),
+ "URL should start with base URL, got: %s", req.Url)
+ require.NotEmpty(t, req.Method, "Method should not be empty for %s", req.Name)
+ require.NotEmpty(t, req.Name, "Name should not be empty")
+ }
+}
+
+func TestRealWorld_PetstoreSwagger2_Methods(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ methodCounts := map[string]int{}
+ for _, req := range resolved.HTTPRequests {
+ methodCounts[req.Method]++
+ }
+
+ // Petstore: 6 GET, 3 POST, 2 PUT, 3 DELETE
+ require.Equal(t, 6, methodCounts["GET"], "Should have 6 GET operations")
+ require.Equal(t, 3, methodCounts["POST"], "Should have 3 POST operations")
+ require.Equal(t, 2, methodCounts["PUT"], "Should have 2 PUT operations")
+ require.Equal(t, 3, methodCounts["DELETE"], "Should have 3 DELETE operations")
+}
+
+func TestRealWorld_PetstoreSwagger2_PathParams(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Find GET /pet/{petId} - should have path param replaced with example value
+ var getPet *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Find pet by ID" {
+ getPet = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, getPet, "Should find 'Find pet by ID' request")
+ require.Equal(t, "https://petstore.swagger.io/v2/pet/42", getPet.Url,
+ "Path param {petId} should be replaced with example value 42")
+
+ // Find GET /user/{username} - should have path param replaced with example
+ var getUser *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Get user by user name" {
+ getUser = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, getUser, "Should find 'Get user by user name' request")
+ require.Equal(t, "https://petstore.swagger.io/v2/user/johndoe", getUser.Url,
+ "Path param {username} should be replaced with example value 'johndoe'")
+}
+
+func TestRealWorld_PetstoreSwagger2_QueryParams(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Find GET /pet/findByStatus - should have 'status' query param
+ var findByStatus *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Finds Pets by status" {
+ findByStatus = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, findByStatus, "Should find 'Finds Pets by status' request")
+
+ var statusParam *mhttp.HTTPSearchParam
+ for i := range resolved.SearchParams {
+ if resolved.SearchParams[i].HttpID == findByStatus.ID && resolved.SearchParams[i].Key == "status" {
+ statusParam = &resolved.SearchParams[i]
+ break
+ }
+ }
+ require.NotNil(t, statusParam, "Should find 'status' query param")
+ require.Equal(t, "available", statusParam.Value, "Should have example value 'available'")
+ require.True(t, statusParam.Enabled, "Required param should be enabled")
+
+ // Find GET /user/login - should have username + password query params
+ var loginUser *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Logs user into the system" {
+ loginUser = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, loginUser, "Should find 'Logs user into the system' request")
+
+ loginParams := map[string]string{}
+ for _, sp := range resolved.SearchParams {
+ if sp.HttpID == loginUser.ID {
+ loginParams[sp.Key] = sp.Value
+ }
+ }
+ require.Equal(t, "johndoe", loginParams["username"])
+ require.Equal(t, "pass123", loginParams["password"])
+}
+
+func TestRealWorld_PetstoreSwagger2_Headers(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Find DELETE /pet/{petId} - should have api_key header
+ var deletePet *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Deletes a pet" {
+ deletePet = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, deletePet, "Should find 'Deletes a pet' request")
+
+ var apiKeyHeader *mhttp.HTTPHeader
+ for i := range resolved.Headers {
+ if resolved.Headers[i].HttpID == deletePet.ID && resolved.Headers[i].Key == "api_key" {
+ apiKeyHeader = &resolved.Headers[i]
+ break
+ }
+ }
+ require.NotNil(t, apiKeyHeader, "Should find api_key header")
+ require.Equal(t, "special-key", apiKeyHeader.Value)
+
+ // Find GET /store/inventory - should have Authorization header
+ var getInventory *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Returns pet inventories by status" {
+ getInventory = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, getInventory, "Should find 'Returns pet inventories by status' request")
+
+ var authHeader *mhttp.HTTPHeader
+ for i := range resolved.Headers {
+ if resolved.Headers[i].HttpID == getInventory.ID && resolved.Headers[i].Key == "Authorization" {
+ authHeader = &resolved.Headers[i]
+ break
+ }
+ }
+ require.NotNil(t, authHeader, "Should find Authorization header")
+ require.Equal(t, "Bearer abc123", authHeader.Value)
+}
+
+func TestRealWorld_PetstoreSwagger2_Bodies(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Find POST /pet - should have body with example JSON
+ var addPet *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Add a new pet to the store" {
+ addPet = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, addPet, "Should find 'Add a new pet to the store' request")
+ require.Equal(t, mhttp.HttpBodyKindRaw, addPet.BodyKind, "POST should have raw body kind")
+
+ var addPetBody *mhttp.HTTPBodyRaw
+ for i := range resolved.BodyRaw {
+ if resolved.BodyRaw[i].HttpID == addPet.ID {
+ addPetBody = &resolved.BodyRaw[i]
+ break
+ }
+ }
+ require.NotNil(t, addPetBody, "Should find raw body for 'Add a new pet to the store'")
+ require.NotEmpty(t, addPetBody.RawData, "Body should not be empty")
+
+ // Body should be valid JSON with expected example fields
+ var bodyMap map[string]interface{}
+ err = json.Unmarshal(addPetBody.RawData, &bodyMap)
+ require.NoError(t, err, "Body should be valid JSON")
+ require.Equal(t, "doggie", bodyMap["name"], "Body should contain example name")
+ require.Equal(t, "available", bodyMap["status"], "Body should contain example status")
+
+ // Content-Type header should be present
+ var ctHeader *mhttp.HTTPHeader
+ for i := range resolved.Headers {
+ if resolved.Headers[i].HttpID == addPet.ID && resolved.Headers[i].Key == "Content-Type" {
+ ctHeader = &resolved.Headers[i]
+ break
+ }
+ }
+ require.NotNil(t, ctHeader, "POST requests should have Content-Type header")
+ require.Equal(t, "application/json", ctHeader.Value)
+}
+
+// --- Real-world OpenAPI 3.0 (Stripe-like) ---
+
+func TestRealWorld_StripeOpenAPI3(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{
+ WorkspaceID: idwrap.NewNow(),
+ }
+
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Stripe-like API has 9 operations
+ require.Equal(t, 9, len(resolved.HTTPRequests), "Should import all 9 operations")
+
+ require.Equal(t, "Stripe-like Payment API", resolved.Flow.Name)
+
+ // Nodes: 1 start + 9 request
+ require.Equal(t, 10, len(resolved.Nodes), "Should have 10 nodes (1 start + 9 request)")
+ require.Equal(t, 9, len(resolved.RequestNodes))
+ require.Equal(t, 9, len(resolved.Edges))
+
+ t.Logf("Imported Stripe-like OpenAPI 3.0:")
+ t.Logf(" - Requests: %d", len(resolved.HTTPRequests))
+ t.Logf(" - Flow Nodes: %d", len(resolved.Nodes))
+ t.Logf(" - Files/Folders: %d", len(resolved.Files))
+ t.Logf(" - Headers: %d", len(resolved.Headers))
+ t.Logf(" - Query Params: %d", len(resolved.SearchParams))
+ t.Logf(" - Body Raw: %d", len(resolved.BodyRaw))
+
+ // All URLs should use the first server (production)
+ for _, req := range resolved.HTTPRequests {
+ require.True(t, strings.HasPrefix(req.Url, "https://api.stripe-example.com/v1"),
+ "URL should use first server URL, got: %s", req.Url)
+ }
+}
+
+func TestRealWorld_StripeOpenAPI3_PathLevelParams(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // /customers/{customerId} has path-level param shared by GET, POST, DELETE
+ var retrieveCustomer *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Retrieve a customer" {
+ retrieveCustomer = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, retrieveCustomer, "Should find 'Retrieve a customer' request")
+ require.Equal(t, "https://api.stripe-example.com/v1/customers/cus_abc123", retrieveCustomer.Url,
+ "Path-level param {customerId} should be resolved to example value")
+
+ // DELETE /customers/{customerId} should also resolve the path param
+ var deleteCustomer *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Delete a customer" {
+ deleteCustomer = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, deleteCustomer, "Should find 'Delete a customer' request")
+ require.Equal(t, "https://api.stripe-example.com/v1/customers/cus_abc123", deleteCustomer.Url)
+}
+
+func TestRealWorld_StripeOpenAPI3_RequestBodies(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // POST /customers should have body with customer fields
+ var createCustomer *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Create a customer" {
+ createCustomer = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, createCustomer, "Should find 'Create a customer' request")
+ require.Equal(t, mhttp.HttpBodyKindRaw, createCustomer.BodyKind)
+
+ var body *mhttp.HTTPBodyRaw
+ for i := range resolved.BodyRaw {
+ if resolved.BodyRaw[i].HttpID == createCustomer.ID {
+ body = &resolved.BodyRaw[i]
+ break
+ }
+ }
+ require.NotNil(t, body, "Should have body for 'Create a customer'")
+
+ var bodyMap map[string]interface{}
+ err = json.Unmarshal(body.RawData, &bodyMap)
+ require.NoError(t, err, "Body should be valid JSON")
+ require.Equal(t, "jenny@example.com", bodyMap["email"])
+ require.Equal(t, "Jenny Rosen", bodyMap["name"])
+
+ // POST /charges should have body with charge fields
+ var createCharge *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Create a charge" {
+ createCharge = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, createCharge, "Should find 'Create a charge' request")
+
+ var chargeBody *mhttp.HTTPBodyRaw
+ for i := range resolved.BodyRaw {
+ if resolved.BodyRaw[i].HttpID == createCharge.ID {
+ chargeBody = &resolved.BodyRaw[i]
+ break
+ }
+ }
+ require.NotNil(t, chargeBody, "Should have body for 'Create a charge'")
+
+ var chargeMap map[string]interface{}
+ err = json.Unmarshal(chargeBody.RawData, &chargeMap)
+ require.NoError(t, err)
+ require.Equal(t, "usd", chargeMap["currency"])
+ require.Equal(t, "cus_abc123", chargeMap["customer"])
+}
+
+func TestRealWorld_StripeOpenAPI3_MultipleHeaders(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // POST /customers should have Authorization + Idempotency-Key + Content-Type
+ var createCustomer *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Create a customer" {
+ createCustomer = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ require.NotNil(t, createCustomer)
+
+ headerMap := map[string]string{}
+ for _, h := range resolved.Headers {
+ if h.HttpID == createCustomer.ID {
+ headerMap[h.Key] = h.Value
+ }
+ }
+ require.Equal(t, "Bearer sk_test_123456", headerMap["Authorization"])
+ require.Equal(t, "unique-key-123", headerMap["Idempotency-Key"])
+ require.Equal(t, "application/json", headerMap["Content-Type"])
+}
+
+// --- Structural integrity tests ---
+
+func TestRealWorld_FlowStructureIntegrity(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ flowID := resolved.Flow.ID
+
+ // Every node should belong to the flow
+ for _, node := range resolved.Nodes {
+ require.Equal(t, flowID, node.FlowID, "Node %q should belong to flow", node.Name)
+ }
+
+ // Every edge should belong to the flow and reference valid nodes
+ nodeIDs := map[idwrap.IDWrap]bool{}
+ for _, node := range resolved.Nodes {
+ nodeIDs[node.ID] = true
+ }
+ for i, edge := range resolved.Edges {
+ require.Equal(t, flowID, edge.FlowID, "Edge %d should belong to flow", i)
+ require.True(t, nodeIDs[edge.SourceID], "Edge %d source should be a valid node", i)
+ require.True(t, nodeIDs[edge.TargetID], "Edge %d target should be a valid node", i)
+ }
+
+ // Start node should exist
+ var startNode *mflow.Node
+ for i := range resolved.Nodes {
+ if resolved.Nodes[i].NodeKind == mflow.NODE_KIND_MANUAL_START {
+ startNode = &resolved.Nodes[i]
+ break
+ }
+ }
+ require.NotNil(t, startNode, "Should have a start node")
+
+ // First edge should come from the start node
+ require.Equal(t, startNode.ID, resolved.Edges[0].SourceID, "First edge should originate from start node")
+
+ // Each request node should reference a valid HTTP request
+ for _, rn := range resolved.RequestNodes {
+ require.NotNil(t, rn.HttpID, "Request node should have an HttpID")
+ found := false
+ for _, req := range resolved.HTTPRequests {
+ if req.ID == *rn.HttpID {
+ found = true
+ break
+ }
+ }
+ require.True(t, found, "Request node's HttpID should match an HTTP request")
+ }
+}
+
+func TestRealWorld_FileStructureIntegrity(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ // Every HTTP request should have a corresponding file
+ httpFileCount := 0
+ flowFileCount := 0
+ folderCount := 0
+ for _, f := range resolved.Files {
+ switch f.ContentType {
+ case mfile.ContentTypeHTTP:
+ httpFileCount++
+ case mfile.ContentTypeFlow:
+ flowFileCount++
+ case mfile.ContentTypeFolder:
+ folderCount++
+ }
+ require.Equal(t, opts.WorkspaceID, f.WorkspaceID, "File should belong to workspace")
+ }
+
+ require.Equal(t, len(resolved.HTTPRequests), httpFileCount,
+ "Each HTTP request should have a file entry")
+ require.Equal(t, 1, flowFileCount, "Should have exactly 1 flow file")
+ require.Greater(t, folderCount, 0, "Should have at least 1 folder")
+
+ t.Logf("Files: %d HTTP, %d flow, %d folders", httpFileCount, flowFileCount, folderCount)
+}
+
+func TestRealWorld_NoDuplicateIDs(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ allIDs := map[idwrap.IDWrap]string{}
+
+ for _, req := range resolved.HTTPRequests {
+ key := "http:" + req.Name
+ existing, dup := allIDs[req.ID]
+ require.False(t, dup, "Duplicate HTTP ID: %s and %s", existing, key)
+ allIDs[req.ID] = key
+ }
+
+ for _, node := range resolved.Nodes {
+ key := "node:" + node.Name
+ existing, dup := allIDs[node.ID]
+ require.False(t, dup, "Duplicate node ID: %s and %s", existing, key)
+ allIDs[node.ID] = key
+ }
+
+ for i, edge := range resolved.Edges {
+ key := "edge:" + string(rune(i))
+ existing, dup := allIDs[edge.ID]
+ require.False(t, dup, "Duplicate edge ID: %s and %s", existing, key)
+ allIDs[edge.ID] = key
+ }
+}
+
+func TestRealWorld_AllWorkspaceIDsConsistent(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "petstore_swagger2.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ wsID := idwrap.NewNow()
+ opts := ConvertOptions{WorkspaceID: wsID}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ require.Equal(t, wsID, resolved.Flow.WorkspaceID)
+ for _, req := range resolved.HTTPRequests {
+ require.Equal(t, wsID, req.WorkspaceID, "HTTP request %q should have correct workspace", req.Name)
+ }
+ for _, f := range resolved.Files {
+ require.Equal(t, wsID, f.WorkspaceID, "File %q should have correct workspace", f.Name)
+ }
+}
+
+// --- GET-only vs POST-body distinction ---
+
+func TestRealWorld_GETRequestsHaveNoBodies(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ bodyHTTPIDs := map[idwrap.IDWrap]bool{}
+ for _, br := range resolved.BodyRaw {
+ bodyHTTPIDs[br.HttpID] = true
+ }
+
+ for _, req := range resolved.HTTPRequests {
+ if req.Method == "GET" || req.Method == "DELETE" {
+ require.Equal(t, mhttp.HttpBodyKindNone, req.BodyKind,
+ "%s %s should have no body kind", req.Method, req.Name)
+ require.False(t, bodyHTTPIDs[req.ID],
+ "%s %s should have no raw body data", req.Method, req.Name)
+ }
+ }
+}
+
+func TestRealWorld_POSTRequestsHaveBodies(t *testing.T) {
+ path := filepath.Join("..", "..", "..", "test", "openapi", "stripe_openapi3.json")
+ data, err := os.ReadFile(path)
+ require.NoError(t, err)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ require.NoError(t, err)
+
+ bodyHTTPIDs := map[idwrap.IDWrap]bool{}
+ for _, br := range resolved.BodyRaw {
+ bodyHTTPIDs[br.HttpID] = true
+ }
+
+ for _, req := range resolved.HTTPRequests {
+ if req.Method == "POST" {
+ // POST requests in the Stripe spec all have requestBody
+ require.Equal(t, mhttp.HttpBodyKindRaw, req.BodyKind,
+ "POST %s should have raw body kind", req.Name)
+ require.True(t, bodyHTTPIDs[req.ID],
+ "POST %s should have raw body data", req.Name)
+ }
+ }
+}
diff --git a/packages/server/pkg/translate/topenapiv2/topenapiv2.go b/packages/server/pkg/translate/topenapiv2/topenapiv2.go
new file mode 100644
index 00000000..e54227c2
--- /dev/null
+++ b/packages/server/pkg/translate/topenapiv2/topenapiv2.go
@@ -0,0 +1,873 @@
+//nolint:revive // exported
+package topenapiv2
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/url"
+ "path"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mfile"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mflow"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mhttp"
+
+ "gopkg.in/yaml.v3"
+)
+
+// OpenAPIResolved contains all resolved HTTP requests and associated data from an OpenAPI/Swagger spec
+type OpenAPIResolved struct {
+ HTTPRequests []mhttp.HTTP
+ Headers []mhttp.HTTPHeader
+ SearchParams []mhttp.HTTPSearchParam
+ BodyRaw []mhttp.HTTPBodyRaw
+ Asserts []mhttp.HTTPAssert
+ Files []mfile.File
+ Flow mflow.Flow
+ Nodes []mflow.Node
+ RequestNodes []mflow.NodeRequest
+ Edges []mflow.Edge
+}
+
+// ConvertOptions defines configuration for OpenAPI spec conversion
+type ConvertOptions struct {
+ WorkspaceID idwrap.IDWrap
+ FolderID *idwrap.IDWrap
+}
+
+// ConvertOpenAPI converts OpenAPI/Swagger spec data (JSON or YAML) to HTTP models.
+// Supports both Swagger 2.0 and OpenAPI 3.x formats.
+func ConvertOpenAPI(data []byte, opts ConvertOptions) (*OpenAPIResolved, error) {
+ if len(data) == 0 {
+ return nil, fmt.Errorf("empty spec data")
+ }
+
+ spec, err := parseSpec(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse OpenAPI spec: %w", err)
+ }
+
+ return convertSpec(spec, opts)
+}
+
+// --- Spec Parsing ---
+
+// spec is a normalized internal representation for both Swagger 2.0 and OpenAPI 3.x
+type spec struct {
+ Title string
+ BaseURL string
+ Paths map[string]pathItem
+}
+
+// pathItem maps HTTP methods to operations
+type pathItem struct {
+ Operations map[string]operation // key: HTTP method (GET, POST, etc.)
+}
+
+// operation represents a single API operation
+type operation struct {
+ Summary string
+ OperationID string
+ Parameters []parameter
+ RequestBody *requestBody
+ Responses map[string]response
+}
+
+// parameter represents an API parameter
+type parameter struct {
+ Name string
+ In string // query, header, path, cookie
+ Required bool
+ Schema *schemaObj
+ Example string
+}
+
+// requestBody represents the request body
+type requestBody struct {
+ ContentType string
+ Example string
+ Schema *schemaObj
+}
+
+// response represents an API response
+type response struct {
+ Description string
+}
+
+// schemaObj is a minimal schema representation to extract example values
+type schemaObj struct {
+ Type string
+ Example interface{}
+ Properties map[string]*schemaObj
+}
+
+// parseSpec parses raw data (JSON or YAML) into our normalized spec.
+func parseSpec(data []byte) (*spec, error) {
+ // Try JSON first, then YAML
+ var raw map[string]interface{}
+ if err := json.Unmarshal(data, &raw); err != nil {
+ if err := yaml.Unmarshal(data, &raw); err != nil {
+ return nil, fmt.Errorf("data is neither valid JSON nor valid YAML: %w", err)
+ }
+ }
+
+ if v, ok := raw["swagger"]; ok {
+ if s, ok := v.(string); ok && strings.HasPrefix(s, "2") {
+ return parseSwagger2(raw)
+ }
+ }
+
+ if v, ok := raw["openapi"]; ok {
+ if s, ok := v.(string); ok && strings.HasPrefix(s, "3") {
+ return parseOpenAPI3(raw)
+ }
+ }
+
+ return nil, fmt.Errorf("unrecognized spec format: missing 'swagger' or 'openapi' field")
+}
+
+// parseSwagger2 parses a Swagger 2.0 spec.
+func parseSwagger2(raw map[string]interface{}) (*spec, error) {
+ s := &spec{
+ Paths: make(map[string]pathItem),
+ }
+
+ // Extract title
+ if info, ok := raw["info"].(map[string]interface{}); ok {
+ if title, ok := info["title"].(string); ok {
+ s.Title = title
+ }
+ }
+
+ // Build base URL from host, basePath, schemes
+ scheme := "https"
+ if schemes, ok := raw["schemes"].([]interface{}); ok && len(schemes) > 0 {
+ if first, ok := schemes[0].(string); ok {
+ scheme = first
+ }
+ }
+ host, _ := raw["host"].(string)
+ basePath, _ := raw["basePath"].(string)
+ if host != "" {
+ s.BaseURL = scheme + "://" + host + basePath
+ }
+
+ // Parse paths
+ paths, ok := raw["paths"].(map[string]interface{})
+ if !ok {
+ return s, nil
+ }
+
+ for pathStr, pathData := range paths {
+ pathMap, ok := pathData.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ pi := pathItem{Operations: make(map[string]operation)}
+
+ // Collect path-level parameters (shared by all operations on this path)
+ var pathParams []parameter
+ if pathParamsRaw, ok := pathMap["parameters"].([]interface{}); ok {
+ pathParams = parseParameters(pathParamsRaw)
+ }
+
+ for method, opData := range pathMap {
+ method = strings.ToUpper(method)
+ if !isHTTPMethod(method) {
+ continue
+ }
+ opMap, ok := opData.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ op := parseSwagger2Operation(opMap, pathParams)
+ pi.Operations[method] = op
+ }
+ s.Paths[pathStr] = pi
+ }
+
+ return s, nil
+}
+
+// parseOpenAPI3 parses an OpenAPI 3.x spec.
+func parseOpenAPI3(raw map[string]interface{}) (*spec, error) {
+ s := &spec{
+ Paths: make(map[string]pathItem),
+ }
+
+ // Extract title
+ if info, ok := raw["info"].(map[string]interface{}); ok {
+ if title, ok := info["title"].(string); ok {
+ s.Title = title
+ }
+ }
+
+ // Extract base URL from servers
+ if servers, ok := raw["servers"].([]interface{}); ok && len(servers) > 0 {
+ if server, ok := servers[0].(map[string]interface{}); ok {
+ if serverURL, ok := server["url"].(string); ok {
+ s.BaseURL = strings.TrimRight(serverURL, "/")
+ }
+ }
+ }
+
+ // Parse paths
+ paths, ok := raw["paths"].(map[string]interface{})
+ if !ok {
+ return s, nil
+ }
+
+ for pathStr, pathData := range paths {
+ pathMap, ok := pathData.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ pi := pathItem{Operations: make(map[string]operation)}
+
+ // Collect path-level parameters
+ var pathParams []parameter
+ if pathParamsRaw, ok := pathMap["parameters"].([]interface{}); ok {
+ pathParams = parseParameters(pathParamsRaw)
+ }
+
+ for method, opData := range pathMap {
+ method = strings.ToUpper(method)
+ if !isHTTPMethod(method) {
+ continue
+ }
+ opMap, ok := opData.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ op := parseOpenAPI3Operation(opMap, pathParams)
+ pi.Operations[method] = op
+ }
+ s.Paths[pathStr] = pi
+ }
+
+ return s, nil
+}
+
+// parseSwagger2Operation parses a Swagger 2.0 operation.
+func parseSwagger2Operation(opMap map[string]interface{}, pathParams []parameter) operation {
+ op := operation{
+ Responses: make(map[string]response),
+ }
+
+ if summary, ok := opMap["summary"].(string); ok {
+ op.Summary = summary
+ }
+ if opID, ok := opMap["operationId"].(string); ok {
+ op.OperationID = opID
+ }
+
+ // Start with path-level parameters
+ op.Parameters = append(op.Parameters, pathParams...)
+
+ // Parse operation-level parameters (override path-level)
+ if paramsRaw, ok := opMap["parameters"].([]interface{}); ok {
+ opParams := parseParameters(paramsRaw)
+ op.Parameters = mergeParameters(op.Parameters, opParams)
+ }
+
+ // In Swagger 2.0, body params are defined as parameters with "in": "body"
+ for i, p := range op.Parameters {
+ if p.In == "body" {
+ op.RequestBody = &requestBody{
+ ContentType: "application/json",
+ Example: p.Example,
+ Schema: p.Schema,
+ }
+ // Remove body param from parameters list
+ op.Parameters = append(op.Parameters[:i], op.Parameters[i+1:]...)
+ break
+ }
+ }
+
+ // Parse responses
+ if responses, ok := opMap["responses"].(map[string]interface{}); ok {
+ for code, respData := range responses {
+ if respMap, ok := respData.(map[string]interface{}); ok {
+ desc, _ := respMap["description"].(string)
+ op.Responses[code] = response{Description: desc}
+ }
+ }
+ }
+
+ return op
+}
+
+// parseOpenAPI3Operation parses an OpenAPI 3.x operation.
+func parseOpenAPI3Operation(opMap map[string]interface{}, pathParams []parameter) operation {
+ op := operation{
+ Responses: make(map[string]response),
+ }
+
+ if summary, ok := opMap["summary"].(string); ok {
+ op.Summary = summary
+ }
+ if opID, ok := opMap["operationId"].(string); ok {
+ op.OperationID = opID
+ }
+
+ // Start with path-level parameters
+ op.Parameters = append(op.Parameters, pathParams...)
+
+ // Parse operation-level parameters
+ if paramsRaw, ok := opMap["parameters"].([]interface{}); ok {
+ opParams := parseParameters(paramsRaw)
+ op.Parameters = mergeParameters(op.Parameters, opParams)
+ }
+
+ // Parse requestBody (OpenAPI 3.x)
+ if rbRaw, ok := opMap["requestBody"].(map[string]interface{}); ok {
+ op.RequestBody = parseRequestBody(rbRaw)
+ }
+
+ // Parse responses
+ if responses, ok := opMap["responses"].(map[string]interface{}); ok {
+ for code, respData := range responses {
+ if respMap, ok := respData.(map[string]interface{}); ok {
+ desc, _ := respMap["description"].(string)
+ op.Responses[code] = response{Description: desc}
+ }
+ }
+ }
+
+ return op
+}
+
+// parseParameters parses a list of parameter objects.
+func parseParameters(paramsRaw []interface{}) []parameter {
+ var params []parameter
+ for _, pRaw := range paramsRaw {
+ pMap, ok := pRaw.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ p := parameter{}
+ p.Name, _ = pMap["name"].(string)
+ p.In, _ = pMap["in"].(string)
+ p.Required, _ = pMap["required"].(bool)
+
+ // Extract example value
+ if example, ok := pMap["example"]; ok {
+ p.Example = fmt.Sprintf("%v", example)
+ }
+
+ // Parse schema for example values
+ if schemaRaw, ok := pMap["schema"].(map[string]interface{}); ok {
+ p.Schema = parseSchema(schemaRaw)
+ if p.Example == "" && p.Schema.Example != nil {
+ p.Example = fmt.Sprintf("%v", p.Schema.Example)
+ }
+ }
+
+ params = append(params, p)
+ }
+ return params
+}
+
+// mergeParameters merges path-level and operation-level parameters.
+// Operation-level parameters override path-level parameters with the same name+in.
+func mergeParameters(pathParams, opParams []parameter) []parameter {
+ merged := make(map[string]parameter)
+ for _, p := range pathParams {
+ merged[p.In+":"+p.Name] = p
+ }
+ for _, p := range opParams {
+ merged[p.In+":"+p.Name] = p
+ }
+ result := make([]parameter, 0, len(merged))
+ for _, p := range merged {
+ result = append(result, p)
+ }
+ return result
+}
+
+// parseRequestBody parses an OpenAPI 3.x requestBody.
+func parseRequestBody(rbMap map[string]interface{}) *requestBody {
+ rb := &requestBody{}
+
+ content, ok := rbMap["content"].(map[string]interface{})
+ if !ok {
+ return rb
+ }
+
+ // Prefer application/json, fall back to first content type
+ for ct, ctData := range content {
+ rb.ContentType = ct
+ if ctMap, ok := ctData.(map[string]interface{}); ok {
+ if schemaRaw, ok := ctMap["schema"].(map[string]interface{}); ok {
+ rb.Schema = parseSchema(schemaRaw)
+ }
+ if example, ok := ctMap["example"]; ok {
+ if exBytes, err := json.Marshal(example); err == nil {
+ rb.Example = string(exBytes)
+ }
+ }
+ }
+ if ct == "application/json" {
+ break
+ }
+ }
+
+ return rb
+}
+
+// parseSchema parses a minimal schema object.
+func parseSchema(raw map[string]interface{}) *schemaObj {
+ s := &schemaObj{}
+ s.Type, _ = raw["type"].(string)
+ s.Example = raw["example"]
+ if props, ok := raw["properties"].(map[string]interface{}); ok {
+ s.Properties = make(map[string]*schemaObj)
+ for key, val := range props {
+ if propMap, ok := val.(map[string]interface{}); ok {
+ s.Properties[key] = parseSchema(propMap)
+ }
+ }
+ }
+ return s
+}
+
+// --- Conversion to HTTP Models ---
+
+// convertSpec converts a parsed spec into resolved HTTP models.
+func convertSpec(s *spec, opts ConvertOptions) (*OpenAPIResolved, error) {
+ resolved := &OpenAPIResolved{}
+
+ // Create flow
+ flowID := idwrap.NewNow()
+ flowName := s.Title
+ if flowName == "" {
+ flowName = "Imported OpenAPI Spec"
+ }
+ resolved.Flow = mflow.Flow{
+ ID: flowID,
+ WorkspaceID: opts.WorkspaceID,
+ Name: flowName,
+ }
+
+ // Create start node
+ startNodeID := idwrap.NewNow()
+ resolved.Nodes = append(resolved.Nodes, mflow.Node{
+ ID: startNodeID,
+ FlowID: flowID,
+ Name: "Start",
+ NodeKind: mflow.NODE_KIND_MANUAL_START,
+ PositionX: 0,
+ PositionY: 0,
+ })
+
+ previousNodeID := startNodeID
+
+ // Sort paths for deterministic output
+ sortedPaths := sortedKeys(s.Paths)
+
+ for _, pathStr := range sortedPaths {
+ pi := s.Paths[pathStr]
+
+ // Sort methods for deterministic output
+ sortedMethods := sortedKeys(pi.Operations)
+
+ for _, method := range sortedMethods {
+ op := pi.Operations[method]
+
+ httpReq, headers, searchParams, bodyRaw, assert := convertOperation(
+ method, pathStr, s.BaseURL, op, opts,
+ )
+
+ // Create flow node
+ nodeID := idwrap.NewNow()
+ node := mflow.Node{
+ ID: nodeID,
+ FlowID: flowID,
+ Name: fmt.Sprintf("http_%d", len(resolved.RequestNodes)+1),
+ NodeKind: mflow.NODE_KIND_REQUEST,
+ PositionX: float64(len(resolved.RequestNodes)+1) * 300,
+ PositionY: 0,
+ }
+
+ reqNode := mflow.NodeRequest{
+ FlowNodeID: nodeID,
+ HttpID: &httpReq.ID,
+ }
+
+ // Edge from previous node
+ resolved.Edges = append(resolved.Edges, mflow.Edge{
+ ID: idwrap.NewNow(),
+ FlowID: flowID,
+ SourceID: previousNodeID,
+ TargetID: nodeID,
+ SourceHandler: mflow.HandleUnspecified,
+ })
+ previousNodeID = nodeID
+
+ // Create file record
+ file := createFileRecord(httpReq, opts)
+
+ // Collect entities
+ resolved.HTTPRequests = append(resolved.HTTPRequests, httpReq)
+ resolved.Headers = append(resolved.Headers, headers...)
+ resolved.SearchParams = append(resolved.SearchParams, searchParams...)
+ if bodyRaw != nil {
+ resolved.BodyRaw = append(resolved.BodyRaw, *bodyRaw)
+ }
+ if assert != nil {
+ resolved.Asserts = append(resolved.Asserts, *assert)
+ }
+ resolved.Files = append(resolved.Files, file)
+ resolved.Nodes = append(resolved.Nodes, node)
+ resolved.RequestNodes = append(resolved.RequestNodes, reqNode)
+ }
+ }
+
+ // Create folder structure from URLs
+ folderFiles := buildFolderStructure(resolved.HTTPRequests, resolved.Files, opts)
+ resolved.Files = append(resolved.Files, folderFiles...)
+
+ // Create flow file entry
+ flowFile := mfile.File{
+ ID: resolved.Flow.ID,
+ WorkspaceID: opts.WorkspaceID,
+ ContentID: &resolved.Flow.ID,
+ ContentType: mfile.ContentTypeFlow,
+ Name: resolved.Flow.Name,
+ Order: -1,
+ UpdatedAt: time.Now(),
+ }
+ resolved.Files = append(resolved.Files, flowFile)
+
+ return resolved, nil
+}
+
+// convertOperation converts a single API operation to HTTP models.
+func convertOperation(
+ method, pathStr, baseURL string,
+ op operation,
+ opts ConvertOptions,
+) (mhttp.HTTP, []mhttp.HTTPHeader, []mhttp.HTTPSearchParam, *mhttp.HTTPBodyRaw, *mhttp.HTTPAssert) {
+ httpID := idwrap.NewNow()
+ now := time.Now().UnixMilli()
+
+ // Build URL with path parameter placeholders
+ fullURL := baseURL + pathStr
+ for _, p := range op.Parameters {
+ if p.In == "path" {
+ // Replace {param} with example value or placeholder
+ value := p.Example
+ if value == "" {
+ value = "{{" + p.Name + "}}"
+ }
+ fullURL = strings.ReplaceAll(fullURL, "{"+p.Name+"}", value)
+ }
+ }
+
+ // Build request name
+ name := op.Summary
+ if name == "" {
+ name = op.OperationID
+ }
+ if name == "" {
+ name = method + " " + pathStr
+ }
+
+ // Determine body kind
+ bodyKind := mhttp.HttpBodyKindNone
+ if op.RequestBody != nil {
+ bodyKind = mhttp.HttpBodyKindRaw
+ }
+
+ httpReq := mhttp.HTTP{
+ ID: httpID,
+ WorkspaceID: opts.WorkspaceID,
+ FolderID: opts.FolderID,
+ Name: name,
+ Url: fullURL,
+ Method: method,
+ Description: op.Summary,
+ BodyKind: bodyKind,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ // Convert headers
+ var headers []mhttp.HTTPHeader
+ headerOrder := 0
+ for _, p := range op.Parameters {
+ if p.In == "header" {
+ headers = append(headers, mhttp.HTTPHeader{
+ ID: idwrap.NewNow(),
+ HttpID: httpID,
+ Key: p.Name,
+ Value: p.Example,
+ Enabled: true,
+ DisplayOrder: float32(headerOrder),
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
+ headerOrder++
+ }
+ }
+
+ // Add Content-Type header if there's a request body
+ if op.RequestBody != nil && op.RequestBody.ContentType != "" {
+ headers = append(headers, mhttp.HTTPHeader{
+ ID: idwrap.NewNow(),
+ HttpID: httpID,
+ Key: "Content-Type",
+ Value: op.RequestBody.ContentType,
+ Enabled: true,
+ DisplayOrder: float32(headerOrder),
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
+ }
+
+ // Convert query parameters
+ var searchParams []mhttp.HTTPSearchParam
+ paramOrder := 0
+ for _, p := range op.Parameters {
+ if p.In == "query" {
+ searchParams = append(searchParams, mhttp.HTTPSearchParam{
+ ID: idwrap.NewNow(),
+ HttpID: httpID,
+ Key: p.Name,
+ Value: p.Example,
+ Enabled: p.Required,
+ DisplayOrder: float64(paramOrder),
+ CreatedAt: now,
+ UpdatedAt: now,
+ })
+ paramOrder++
+ }
+ }
+
+ // Convert request body
+ var bodyRaw *mhttp.HTTPBodyRaw
+ if op.RequestBody != nil {
+ bodyContent := op.RequestBody.Example
+ if bodyContent == "" && op.RequestBody.Schema != nil {
+ bodyContent = generateExampleJSON(op.RequestBody.Schema)
+ }
+ if bodyContent != "" {
+ bodyRaw = &mhttp.HTTPBodyRaw{
+ ID: idwrap.NewNow(),
+ HttpID: httpID,
+ RawData: []byte(bodyContent),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ }
+ }
+
+ // Create status code assertion from first success response
+ var assert *mhttp.HTTPAssert
+ for code := range op.Responses {
+ if strings.HasPrefix(code, "2") {
+ statusCode := 200
+ if _, err := fmt.Sscanf(code, "%d", &statusCode); err == nil {
+ assert = &mhttp.HTTPAssert{
+ ID: idwrap.NewNow(),
+ HttpID: httpID,
+ Value: fmt.Sprintf("response.status == %d", statusCode),
+ Enabled: true,
+ Description: fmt.Sprintf("Verify response status is %d (from OpenAPI import)", statusCode),
+ DisplayOrder: 0,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ }
+ break
+ }
+ }
+
+ return httpReq, headers, searchParams, bodyRaw, assert
+}
+
+// --- Helper Functions ---
+
+// generateExampleJSON generates a minimal example JSON from a schema.
+func generateExampleJSON(s *schemaObj) string {
+ if s == nil {
+ return ""
+ }
+ if s.Example != nil {
+ if b, err := json.MarshalIndent(s.Example, "", " "); err == nil {
+ return string(b)
+ }
+ }
+ if len(s.Properties) > 0 {
+ obj := make(map[string]interface{})
+ for key, prop := range s.Properties {
+ if prop.Example != nil {
+ obj[key] = prop.Example
+ } else {
+ obj[key] = exampleForType(prop.Type)
+ }
+ }
+ if b, err := json.MarshalIndent(obj, "", " "); err == nil {
+ return string(b)
+ }
+ }
+ return ""
+}
+
+// exampleForType returns a placeholder value for a JSON schema type.
+func exampleForType(t string) interface{} {
+ switch t {
+ case "string":
+ return "string"
+ case "integer", "number":
+ return 0
+ case "boolean":
+ return false
+ case "array":
+ return []interface{}{}
+ case "object":
+ return map[string]interface{}{}
+ default:
+ return nil
+ }
+}
+
+// createFileRecord creates a file record for an HTTP request.
+func createFileRecord(httpReq mhttp.HTTP, opts ConvertOptions) mfile.File {
+ filename := httpReq.Name
+ if filename == "" {
+ filename = "untitled_request"
+ }
+ return mfile.File{
+ ID: httpReq.ID,
+ WorkspaceID: opts.WorkspaceID,
+ ParentID: httpReq.FolderID,
+ ContentID: &httpReq.ID,
+ ContentType: mfile.ContentTypeHTTP,
+ Name: filename,
+ Order: 0,
+ UpdatedAt: time.Now(),
+ }
+}
+
+// buildFolderStructure creates URL-based folder structure similar to Postman and HAR imports.
+func buildFolderStructure(httpReqs []mhttp.HTTP, existingFiles []mfile.File, opts ConvertOptions) []mfile.File {
+ folderMap := make(map[string]idwrap.IDWrap)
+ folderFiles := make(map[string]mfile.File)
+
+ for i := range httpReqs {
+ req := &httpReqs[i]
+ if req.FolderID != nil {
+ continue // Already has a folder
+ }
+
+ folderPath := buildFolderPathFromURL(req.Url)
+ if folderPath == "" || folderPath == "/" {
+ continue
+ }
+
+ folderID := getOrCreateFolder(folderPath, opts.WorkspaceID, folderMap, folderFiles)
+ if folderID.Compare(idwrap.IDWrap{}) != 0 {
+ req.FolderID = &folderID
+ // Also update the corresponding file's parent
+ for j := range existingFiles {
+ if existingFiles[j].ID == req.ID {
+ existingFiles[j].ParentID = &folderID
+ break
+ }
+ }
+ }
+ }
+
+ result := make([]mfile.File, 0, len(folderFiles))
+ for _, f := range folderFiles {
+ result = append(result, f)
+ }
+ return result
+}
+
+// getOrCreateFolder creates or retrieves a folder ID for a given path.
+func getOrCreateFolder(folderPath string, workspaceID idwrap.IDWrap, folderMap map[string]idwrap.IDWrap, folderFiles map[string]mfile.File) idwrap.IDWrap {
+ if existingID, exists := folderMap[folderPath]; exists {
+ return existingID
+ }
+
+ // Create parent folders first
+ var parentID *idwrap.IDWrap
+ parentPath := path.Dir(folderPath)
+ if parentPath != "/" && parentPath != "." && parentPath != "" {
+ pid := getOrCreateFolder(parentPath, workspaceID, folderMap, folderFiles)
+ parentID = &pid
+ }
+
+ folderID := idwrap.NewNow()
+ folderName := path.Base(folderPath)
+ if folderName == "" || folderName == "." || folderName == "/" {
+ folderName = "imported"
+ }
+
+ folderFiles[folderPath] = mfile.File{
+ ID: folderID,
+ WorkspaceID: workspaceID,
+ ParentID: parentID,
+ ContentType: mfile.ContentTypeFolder,
+ Name: folderName,
+ Order: 0,
+ UpdatedAt: time.Now(),
+ }
+ folderMap[folderPath] = folderID
+ return folderID
+}
+
+// buildFolderPathFromURL creates a hierarchical folder path from a URL.
+func buildFolderPathFromURL(urlStr string) string {
+ parsedURL, err := url.Parse(urlStr)
+ if err != nil {
+ return ""
+ }
+
+ hostname := parsedURL.Hostname()
+ if hostname == "" {
+ return ""
+ }
+
+ // Reverse hostname: api.example.com -> com/example/api
+ hostParts := strings.Split(hostname, ".")
+ for i, j := 0, len(hostParts)-1; i < j; i, j = i+1, j-1 {
+ hostParts[i], hostParts[j] = hostParts[j], hostParts[i]
+ }
+
+ var allSegments []string
+ for _, part := range hostParts {
+ if part != "" {
+ allSegments = append(allSegments, part)
+ }
+ }
+
+ if len(allSegments) == 0 {
+ return ""
+ }
+ return "/" + strings.Join(allSegments, "/")
+}
+
+// isHTTPMethod checks if a string is a valid HTTP method.
+func isHTTPMethod(s string) bool {
+ switch s {
+ case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE":
+ return true
+ }
+ return false
+}
+
+// sortedKeys returns sorted keys from a map.
+func sortedKeys[V any](m map[string]V) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/packages/server/pkg/translate/topenapiv2/topenapiv2_test.go b/packages/server/pkg/translate/topenapiv2/topenapiv2_test.go
new file mode 100644
index 00000000..7f75dc53
--- /dev/null
+++ b/packages/server/pkg/translate/topenapiv2/topenapiv2_test.go
@@ -0,0 +1,468 @@
+package topenapiv2
+
+import (
+ "testing"
+
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/idwrap"
+ "github.com/the-dev-tools/dev-tools/packages/server/pkg/model/mhttp"
+)
+
+func TestConvertOpenAPI_Swagger2(t *testing.T) {
+ swaggerJSON := []byte(`{
+ "swagger": "2.0",
+ "info": {"title": "Pet Store", "version": "1.0.0"},
+ "host": "petstore.swagger.io",
+ "basePath": "/v2",
+ "schemes": ["https"],
+ "paths": {
+ "/pets": {
+ "get": {
+ "summary": "List all pets",
+ "operationId": "listPets",
+ "parameters": [
+ {"name": "limit", "in": "query", "type": "integer", "required": false, "example": 10}
+ ],
+ "responses": {
+ "200": {"description": "A list of pets"}
+ }
+ },
+ "post": {
+ "summary": "Create a pet",
+ "operationId": "createPet",
+ "parameters": [
+ {
+ "name": "body",
+ "in": "body",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "example": "doggie"},
+ "status": {"type": "string", "example": "available"}
+ }
+ }
+ }
+ ],
+ "responses": {
+ "201": {"description": "Pet created"}
+ }
+ }
+ },
+ "/pets/{petId}": {
+ "get": {
+ "summary": "Get a pet by ID",
+ "operationId": "getPet",
+ "parameters": [
+ {"name": "petId", "in": "path", "type": "integer", "required": true, "example": 123}
+ ],
+ "responses": {
+ "200": {"description": "A pet"}
+ }
+ }
+ }
+ }
+ }`)
+
+ opts := ConvertOptions{
+ WorkspaceID: idwrap.NewNow(),
+ }
+
+ resolved, err := ConvertOpenAPI(swaggerJSON, opts)
+ if err != nil {
+ t.Fatalf("ConvertOpenAPI() error = %v", err)
+ }
+
+ // Should have 3 HTTP requests (GET /pets, POST /pets, GET /pets/{petId})
+ if len(resolved.HTTPRequests) != 3 {
+ t.Errorf("expected 3 HTTP requests, got %d", len(resolved.HTTPRequests))
+ }
+
+ // Verify flow was created
+ if resolved.Flow.Name != "Pet Store" {
+ t.Errorf("expected flow name 'Pet Store', got %q", resolved.Flow.Name)
+ }
+
+ // Verify base URL
+ for _, req := range resolved.HTTPRequests {
+ if req.Url == "" {
+ t.Errorf("HTTP request %q has empty URL", req.Name)
+ }
+ if req.Method == "" {
+ t.Errorf("HTTP request %q has empty method", req.Name)
+ }
+ }
+
+ // Find the GET /pets request and verify query params
+ var getReq *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Method == "GET" && resolved.HTTPRequests[i].Name == "List all pets" {
+ getReq = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ if getReq == nil {
+ t.Fatal("could not find GET /pets request")
+ }
+
+ // Verify query parameters
+ var foundLimit bool
+ for _, sp := range resolved.SearchParams {
+ if sp.HttpID == getReq.ID && sp.Key == "limit" {
+ foundLimit = true
+ if sp.Value != "10" {
+ t.Errorf("expected limit value '10', got %q", sp.Value)
+ }
+ }
+ }
+ if !foundLimit {
+ t.Error("expected to find 'limit' query parameter")
+ }
+
+ // Find POST /pets and verify body
+ var postReq *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Method == "POST" {
+ postReq = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ if postReq == nil {
+ t.Fatal("could not find POST /pets request")
+ }
+ if postReq.BodyKind != mhttp.HttpBodyKindRaw {
+ t.Errorf("expected body kind Raw, got %v", postReq.BodyKind)
+ }
+
+ // Verify body raw exists for POST
+ var foundBody bool
+ for _, br := range resolved.BodyRaw {
+ if br.HttpID == postReq.ID {
+ foundBody = true
+ if len(br.RawData) == 0 {
+ t.Error("expected non-empty body raw data")
+ }
+ }
+ }
+ if !foundBody {
+ t.Error("expected to find body raw for POST request")
+ }
+
+ // Verify path parameter replacement
+ var getPetReq *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Name == "Get a pet by ID" {
+ getPetReq = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ if getPetReq == nil {
+ t.Fatal("could not find GET /pets/{petId} request")
+ }
+ if getPetReq.Url != "https://petstore.swagger.io/v2/pets/123" {
+ t.Errorf("expected URL with petId replaced, got %q", getPetReq.Url)
+ }
+
+ // Verify nodes and edges
+ if len(resolved.Nodes) != 4 { // 1 start + 3 request nodes
+ t.Errorf("expected 4 nodes, got %d", len(resolved.Nodes))
+ }
+ if len(resolved.Edges) != 3 {
+ t.Errorf("expected 3 edges, got %d", len(resolved.Edges))
+ }
+
+ // Verify files
+ if len(resolved.Files) == 0 {
+ t.Error("expected files to be created")
+ }
+}
+
+func TestConvertOpenAPI_OpenAPI3(t *testing.T) {
+ openAPI3JSON := []byte(`{
+ "openapi": "3.0.0",
+ "info": {"title": "User API", "version": "1.0.0"},
+ "servers": [{"url": "https://api.example.com/v1"}],
+ "paths": {
+ "/users": {
+ "get": {
+ "summary": "List users",
+ "parameters": [
+ {"name": "Authorization", "in": "header", "required": true, "example": "Bearer token123"},
+ {"name": "page", "in": "query", "required": false, "example": 1}
+ ],
+ "responses": {
+ "200": {"description": "OK"}
+ }
+ },
+ "post": {
+ "summary": "Create user",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "example": "John"},
+ "email": {"type": "string", "example": "john@example.com"}
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {"description": "Created"}
+ }
+ }
+ }
+ }
+ }`)
+
+ opts := ConvertOptions{
+ WorkspaceID: idwrap.NewNow(),
+ }
+
+ resolved, err := ConvertOpenAPI(openAPI3JSON, opts)
+ if err != nil {
+ t.Fatalf("ConvertOpenAPI() error = %v", err)
+ }
+
+ if len(resolved.HTTPRequests) != 2 {
+ t.Errorf("expected 2 HTTP requests, got %d", len(resolved.HTTPRequests))
+ }
+
+ if resolved.Flow.Name != "User API" {
+ t.Errorf("expected flow name 'User API', got %q", resolved.Flow.Name)
+ }
+
+ // Verify GET /users has Authorization header
+ var getUsersReq *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Method == "GET" {
+ getUsersReq = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ if getUsersReq == nil {
+ t.Fatal("could not find GET /users request")
+ }
+
+ var foundAuth bool
+ for _, h := range resolved.Headers {
+ if h.HttpID == getUsersReq.ID && h.Key == "Authorization" {
+ foundAuth = true
+ if h.Value != "Bearer token123" {
+ t.Errorf("expected Authorization value 'Bearer token123', got %q", h.Value)
+ }
+ }
+ }
+ if !foundAuth {
+ t.Error("expected to find Authorization header")
+ }
+
+ // Verify POST /users has Content-Type header
+ var postReq *mhttp.HTTP
+ for i := range resolved.HTTPRequests {
+ if resolved.HTTPRequests[i].Method == "POST" {
+ postReq = &resolved.HTTPRequests[i]
+ break
+ }
+ }
+ if postReq == nil {
+ t.Fatal("could not find POST /users request")
+ }
+
+ var foundContentType bool
+ for _, h := range resolved.Headers {
+ if h.HttpID == postReq.ID && h.Key == "Content-Type" {
+ foundContentType = true
+ if h.Value != "application/json" {
+ t.Errorf("expected Content-Type 'application/json', got %q", h.Value)
+ }
+ }
+ }
+ if !foundContentType {
+ t.Error("expected to find Content-Type header for POST request")
+ }
+}
+
+func TestConvertOpenAPI_YAML(t *testing.T) {
+ yamlSpec := []byte(`
+openapi: "3.0.0"
+info:
+ title: YAML API
+ version: "1.0"
+servers:
+ - url: https://yaml-api.example.com
+paths:
+ /items:
+ get:
+ summary: List items
+ responses:
+ "200":
+ description: Success
+`)
+
+ opts := ConvertOptions{
+ WorkspaceID: idwrap.NewNow(),
+ }
+
+ resolved, err := ConvertOpenAPI(yamlSpec, opts)
+ if err != nil {
+ t.Fatalf("ConvertOpenAPI() error = %v", err)
+ }
+
+ if len(resolved.HTTPRequests) != 1 {
+ t.Errorf("expected 1 HTTP request, got %d", len(resolved.HTTPRequests))
+ }
+
+ if resolved.HTTPRequests[0].Url != "https://yaml-api.example.com/items" {
+ t.Errorf("unexpected URL: %q", resolved.HTTPRequests[0].Url)
+ }
+}
+
+func TestConvertOpenAPI_EmptyData(t *testing.T) {
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ _, err := ConvertOpenAPI([]byte{}, opts)
+ if err == nil {
+ t.Error("expected error for empty data")
+ }
+}
+
+func TestConvertOpenAPI_InvalidData(t *testing.T) {
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ _, err := ConvertOpenAPI([]byte("not json or yaml"), opts)
+ if err == nil {
+ t.Error("expected error for invalid data")
+ }
+}
+
+func TestConvertOpenAPI_NoPathsReturnsEmptyRequests(t *testing.T) {
+ data := []byte(`{"openapi": "3.0.0", "info": {"title": "Empty"}}`)
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(data, opts)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(resolved.HTTPRequests) != 0 {
+ t.Errorf("expected 0 HTTP requests, got %d", len(resolved.HTTPRequests))
+ }
+}
+
+func TestParseSpec_Swagger2(t *testing.T) {
+ data := []byte(`{"swagger": "2.0", "info": {"title": "Test"}, "host": "api.test.com", "basePath": "/v1", "schemes": ["https"]}`)
+ s, err := parseSpec(data)
+ if err != nil {
+ t.Fatalf("parseSpec() error = %v", err)
+ }
+ if s.Title != "Test" {
+ t.Errorf("expected title 'Test', got %q", s.Title)
+ }
+ if s.BaseURL != "https://api.test.com/v1" {
+ t.Errorf("expected base URL 'https://api.test.com/v1', got %q", s.BaseURL)
+ }
+}
+
+func TestParseSpec_OpenAPI3(t *testing.T) {
+ data := []byte(`{"openapi": "3.0.0", "info": {"title": "Test3"}, "servers": [{"url": "https://api3.test.com"}]}`)
+ s, err := parseSpec(data)
+ if err != nil {
+ t.Fatalf("parseSpec() error = %v", err)
+ }
+ if s.Title != "Test3" {
+ t.Errorf("expected title 'Test3', got %q", s.Title)
+ }
+ if s.BaseURL != "https://api3.test.com" {
+ t.Errorf("expected base URL 'https://api3.test.com', got %q", s.BaseURL)
+ }
+}
+
+func TestIsHTTPMethod(t *testing.T) {
+ tests := []struct {
+ method string
+ want bool
+ }{
+ {"GET", true},
+ {"POST", true},
+ {"PUT", true},
+ {"DELETE", true},
+ {"PATCH", true},
+ {"HEAD", true},
+ {"OPTIONS", true},
+ {"parameters", false},
+ {"summary", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ if got := isHTTPMethod(tt.method); got != tt.want {
+ t.Errorf("isHTTPMethod(%q) = %v, want %v", tt.method, got, tt.want)
+ }
+ }
+}
+
+func TestGenerateExampleJSON(t *testing.T) {
+ schema := &schemaObj{
+ Type: "object",
+ Properties: map[string]*schemaObj{
+ "name": {Type: "string", Example: "John"},
+ "age": {Type: "integer"},
+ "email": {Type: "string"},
+ },
+ }
+
+ result := generateExampleJSON(schema)
+ if result == "" {
+ t.Error("expected non-empty example JSON")
+ }
+}
+
+func TestBuildFolderPathFromURL(t *testing.T) {
+ tests := []struct {
+ url string
+ want string
+ }{
+ {"https://api.example.com/v1/users", "/com/example/api"},
+ {"https://localhost:8080/api", "/localhost"},
+ {"", ""},
+ {"not-a-url", ""},
+ }
+ for _, tt := range tests {
+ got := buildFolderPathFromURL(tt.url)
+ if got != tt.want {
+ t.Errorf("buildFolderPathFromURL(%q) = %q, want %q", tt.url, got, tt.want)
+ }
+ }
+}
+
+func TestConvertOpenAPI_PathLevelParameters(t *testing.T) {
+ spec := []byte(`{
+ "openapi": "3.0.0",
+ "info": {"title": "Test"},
+ "servers": [{"url": "https://api.test.com"}],
+ "paths": {
+ "/items/{itemId}": {
+ "parameters": [
+ {"name": "itemId", "in": "path", "required": true, "example": "abc123"}
+ ],
+ "get": {
+ "summary": "Get item",
+ "responses": {"200": {"description": "OK"}}
+ },
+ "put": {
+ "summary": "Update item",
+ "responses": {"200": {"description": "OK"}}
+ }
+ }
+ }
+ }`)
+
+ opts := ConvertOptions{WorkspaceID: idwrap.NewNow()}
+ resolved, err := ConvertOpenAPI(spec, opts)
+ if err != nil {
+ t.Fatalf("ConvertOpenAPI() error = %v", err)
+ }
+
+ // Both GET and PUT should have the path param resolved
+ for _, req := range resolved.HTTPRequests {
+ if req.Url != "https://api.test.com/items/abc123" {
+ t.Errorf("expected URL with itemId replaced, got %q for %s", req.Url, req.Method)
+ }
+ }
+}
diff --git a/packages/server/test/openapi/petstore_swagger2.json b/packages/server/test/openapi/petstore_swagger2.json
new file mode 100644
index 00000000..16c1897d
--- /dev/null
+++ b/packages/server/test/openapi/petstore_swagger2.json
@@ -0,0 +1,364 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "title": "Petstore API",
+ "description": "A sample API that uses a petstore as an example",
+ "version": "1.0.0",
+ "contact": {
+ "name": "API Support",
+ "url": "https://petstore.swagger.io"
+ },
+ "license": {
+ "name": "Apache 2.0"
+ }
+ },
+ "host": "petstore.swagger.io",
+ "basePath": "/v2",
+ "schemes": ["https", "http"],
+ "consumes": ["application/json"],
+ "produces": ["application/json"],
+ "paths": {
+ "/pet": {
+ "post": {
+ "tags": ["pet"],
+ "summary": "Add a new pet to the store",
+ "operationId": "addPet",
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "Pet object that needs to be added to the store",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "example": 10 },
+ "name": { "type": "string", "example": "doggie" },
+ "status": { "type": "string", "example": "available" }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "405": { "description": "Invalid input" }
+ }
+ },
+ "put": {
+ "tags": ["pet"],
+ "summary": "Update an existing pet",
+ "operationId": "updatePet",
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "Pet object that needs to be updated",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "integer", "example": 10 },
+ "name": { "type": "string", "example": "doggie" },
+ "status": { "type": "string", "example": "sold" }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Pet not found" },
+ "405": { "description": "Validation exception" }
+ }
+ }
+ },
+ "/pet/findByStatus": {
+ "get": {
+ "tags": ["pet"],
+ "summary": "Finds Pets by status",
+ "operationId": "findPetsByStatus",
+ "parameters": [
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Status values that need to be considered for filter",
+ "required": true,
+ "type": "array",
+ "items": { "type": "string" },
+ "example": "available"
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid status value" }
+ }
+ }
+ },
+ "/pet/{petId}": {
+ "get": {
+ "tags": ["pet"],
+ "summary": "Find pet by ID",
+ "operationId": "getPetById",
+ "parameters": [
+ {
+ "name": "petId",
+ "in": "path",
+ "description": "ID of pet to return",
+ "required": true,
+ "type": "integer",
+ "example": 42
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Pet not found" }
+ }
+ },
+ "delete": {
+ "tags": ["pet"],
+ "summary": "Deletes a pet",
+ "operationId": "deletePet",
+ "parameters": [
+ {
+ "name": "api_key",
+ "in": "header",
+ "required": false,
+ "type": "string",
+ "example": "special-key"
+ },
+ {
+ "name": "petId",
+ "in": "path",
+ "description": "Pet id to delete",
+ "required": true,
+ "type": "integer",
+ "example": 42
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Pet not found" }
+ }
+ }
+ },
+ "/store/inventory": {
+ "get": {
+ "tags": ["store"],
+ "summary": "Returns pet inventories by status",
+ "operationId": "getInventory",
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "type": "string",
+ "example": "Bearer abc123"
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" }
+ }
+ }
+ },
+ "/store/order": {
+ "post": {
+ "tags": ["store"],
+ "summary": "Place an order for a pet",
+ "operationId": "placeOrder",
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "order placed for purchasing the pet",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "petId": { "type": "integer", "example": 42 },
+ "quantity": { "type": "integer", "example": 1 },
+ "shipDate": { "type": "string", "example": "2025-01-15T00:00:00Z" },
+ "status": { "type": "string", "example": "placed" },
+ "complete": { "type": "boolean", "example": false }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid Order" }
+ }
+ }
+ },
+ "/store/order/{orderId}": {
+ "get": {
+ "tags": ["store"],
+ "summary": "Find purchase order by ID",
+ "operationId": "getOrderById",
+ "parameters": [
+ {
+ "name": "orderId",
+ "in": "path",
+ "description": "ID of pet that needs to be fetched",
+ "required": true,
+ "type": "integer",
+ "example": 1
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Order not found" }
+ }
+ },
+ "delete": {
+ "tags": ["store"],
+ "summary": "Delete purchase order by ID",
+ "operationId": "deleteOrder",
+ "parameters": [
+ {
+ "name": "orderId",
+ "in": "path",
+ "required": true,
+ "type": "integer",
+ "example": 1
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid ID supplied" },
+ "404": { "description": "Order not found" }
+ }
+ }
+ },
+ "/user": {
+ "post": {
+ "tags": ["user"],
+ "summary": "Create user",
+ "operationId": "createUser",
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "Created user object",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "username": { "type": "string", "example": "johndoe" },
+ "email": { "type": "string", "example": "john@example.com" },
+ "password": { "type": "string", "example": "pass123" },
+ "phone": { "type": "string", "example": "1234567890" }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" }
+ }
+ }
+ },
+ "/user/login": {
+ "get": {
+ "tags": ["user"],
+ "summary": "Logs user into the system",
+ "operationId": "loginUser",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "query",
+ "description": "The user name for login",
+ "required": true,
+ "type": "string",
+ "example": "johndoe"
+ },
+ {
+ "name": "password",
+ "in": "query",
+ "description": "The password for login in clear text",
+ "required": true,
+ "type": "string",
+ "example": "pass123"
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid username/password supplied" }
+ }
+ }
+ },
+ "/user/{username}": {
+ "get": {
+ "tags": ["user"],
+ "summary": "Get user by user name",
+ "operationId": "getUserByName",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "path",
+ "required": true,
+ "type": "string",
+ "example": "johndoe"
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid username supplied" },
+ "404": { "description": "User not found" }
+ }
+ },
+ "put": {
+ "tags": ["user"],
+ "summary": "Updated user",
+ "operationId": "updateUser",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "path",
+ "required": true,
+ "type": "string",
+ "example": "johndoe"
+ },
+ {
+ "in": "body",
+ "name": "body",
+ "description": "Updated user object",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "email": { "type": "string", "example": "john_updated@example.com" },
+ "phone": { "type": "string", "example": "0987654321" }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid user supplied" },
+ "404": { "description": "User not found" }
+ }
+ },
+ "delete": {
+ "tags": ["user"],
+ "summary": "Delete user",
+ "operationId": "deleteUser",
+ "parameters": [
+ {
+ "name": "username",
+ "in": "path",
+ "required": true,
+ "type": "string",
+ "example": "johndoe"
+ }
+ ],
+ "responses": {
+ "200": { "description": "successful operation" },
+ "400": { "description": "Invalid username supplied" },
+ "404": { "description": "User not found" }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/server/test/openapi/stripe_openapi3.json b/packages/server/test/openapi/stripe_openapi3.json
new file mode 100644
index 00000000..111c2734
--- /dev/null
+++ b/packages/server/test/openapi/stripe_openapi3.json
@@ -0,0 +1,342 @@
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Stripe-like Payment API",
+ "description": "A realistic payment processing API modeled after Stripe",
+ "version": "2024-01-01",
+ "contact": {
+ "name": "Developer Support",
+ "url": "https://docs.stripe-example.com"
+ }
+ },
+ "servers": [
+ {
+ "url": "https://api.stripe-example.com/v1",
+ "description": "Production server"
+ },
+ {
+ "url": "https://sandbox.stripe-example.com/v1",
+ "description": "Sandbox server"
+ }
+ ],
+ "paths": {
+ "/customers": {
+ "get": {
+ "summary": "List all customers",
+ "operationId": "listCustomers",
+ "tags": ["Customers"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "integer", "maximum": 100 },
+ "example": 10
+ },
+ {
+ "name": "starting_after",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "string" },
+ "example": "cus_abc123"
+ },
+ {
+ "name": "email",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "string" },
+ "example": "jenny@example.com"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "A list of customers",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "object": { "type": "string", "example": "list" },
+ "data": { "type": "array" },
+ "has_more": { "type": "boolean" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create a customer",
+ "operationId": "createCustomer",
+ "tags": ["Customers"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ },
+ {
+ "name": "Idempotency-Key",
+ "in": "header",
+ "required": false,
+ "schema": { "type": "string" },
+ "example": "unique-key-123"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "email": { "type": "string", "example": "jenny@example.com" },
+ "name": { "type": "string", "example": "Jenny Rosen" },
+ "description": { "type": "string", "example": "Premium customer" },
+ "metadata": {
+ "type": "object",
+ "properties": {
+ "order_id": { "type": "string", "example": "6735" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Returns the created Customer object" },
+ "400": { "description": "Bad request" }
+ }
+ }
+ },
+ "/customers/{customerId}": {
+ "parameters": [
+ {
+ "name": "customerId",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "cus_abc123"
+ }
+ ],
+ "get": {
+ "summary": "Retrieve a customer",
+ "operationId": "retrieveCustomer",
+ "tags": ["Customers"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ }
+ ],
+ "responses": {
+ "200": { "description": "Returns the Customer object" },
+ "404": { "description": "Customer not found" }
+ }
+ },
+ "post": {
+ "summary": "Update a customer",
+ "operationId": "updateCustomer",
+ "tags": ["Customers"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "email": { "type": "string", "example": "jenny_updated@example.com" },
+ "name": { "type": "string", "example": "Jenny Rosen-Updated" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Returns the updated Customer object" },
+ "400": { "description": "Bad request" }
+ }
+ },
+ "delete": {
+ "summary": "Delete a customer",
+ "operationId": "deleteCustomer",
+ "tags": ["Customers"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ }
+ ],
+ "responses": {
+ "200": { "description": "Returns a deleted object" },
+ "404": { "description": "Customer not found" }
+ }
+ }
+ },
+ "/charges": {
+ "get": {
+ "summary": "List all charges",
+ "operationId": "listCharges",
+ "tags": ["Charges"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "integer" },
+ "example": 25
+ },
+ {
+ "name": "customer",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "string" },
+ "example": "cus_abc123"
+ }
+ ],
+ "responses": {
+ "200": { "description": "A list of charges" }
+ }
+ },
+ "post": {
+ "summary": "Create a charge",
+ "operationId": "createCharge",
+ "tags": ["Charges"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ },
+ {
+ "name": "Idempotency-Key",
+ "in": "header",
+ "required": false,
+ "schema": { "type": "string" },
+ "example": "charge-key-456"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "amount": { "type": "integer", "example": 2000 },
+ "currency": { "type": "string", "example": "usd" },
+ "customer": { "type": "string", "example": "cus_abc123" },
+ "description": { "type": "string", "example": "Payment for order #1234" },
+ "source": { "type": "string", "example": "tok_visa" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Returns the created Charge object" },
+ "402": { "description": "Card declined" },
+ "400": { "description": "Bad request" }
+ }
+ }
+ },
+ "/charges/{chargeId}": {
+ "parameters": [
+ {
+ "name": "chargeId",
+ "in": "path",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "ch_abc123"
+ }
+ ],
+ "get": {
+ "summary": "Retrieve a charge",
+ "operationId": "retrieveCharge",
+ "tags": ["Charges"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ }
+ ],
+ "responses": {
+ "200": { "description": "Returns the Charge object" },
+ "404": { "description": "Charge not found" }
+ }
+ }
+ },
+ "/refunds": {
+ "post": {
+ "summary": "Create a refund",
+ "operationId": "createRefund",
+ "tags": ["Refunds"],
+ "parameters": [
+ {
+ "name": "Authorization",
+ "in": "header",
+ "required": true,
+ "schema": { "type": "string" },
+ "example": "Bearer sk_test_123456"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "charge": { "type": "string", "example": "ch_abc123" },
+ "amount": { "type": "integer", "example": 1000 },
+ "reason": { "type": "string", "example": "requested_by_customer" }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": { "description": "Returns the created Refund object" },
+ "400": { "description": "Bad request" }
+ }
+ }
+ }
+ }
+}