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" } + } + } + } + } +}