Skip to content

Commit 280a868

Browse files
authored
Merge pull request #30 from the-dev-tools/feat/add-swagger-openapi-imports
feat(import): add Swagger/OpenAPI import and URL fetching support
2 parents f9a125a + f46f81c commit 280a868

13 files changed

Lines changed: 3396 additions & 22 deletions

File tree

‎packages/client/src/widgets/import/index.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,14 @@ const InitialDialog = ({ setModal, successAction }: InitialDialogProps) => {
150150
`}
151151
>
152152
<FiInfo className={tw`mr-1.5 inline-block size-4 align-bottom`} />
153-
Import Postman or HAR files
153+
Import Postman, HAR, Swagger or OpenAPI files, or paste a URL
154154
</div>
155155

156156
<TextInputField
157157
className={tw`mt-4`}
158158
label='Text value'
159159
onChange={setText}
160-
placeholder='Paste cURL, Raw text or URL...'
160+
placeholder='Paste cURL, Swagger/OpenAPI URL, or raw text...'
161161
value={text}
162162
/>
163163

‎packages/server/internal/api/rimportv2/format_detection.go‎

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
FormatJSON
2626
FormatCURL
2727
FormatPostman
28+
FormatOpenAPI
2829
)
2930

3031
const ReasonValidJSON = "Valid JSON; "
@@ -42,6 +43,8 @@ func (f Format) String() string {
4243
return "CURL"
4344
case FormatPostman:
4445
return "Postman"
46+
case FormatOpenAPI:
47+
return "OpenAPI"
4548
default:
4649
return "Unknown"
4750
}
@@ -57,19 +60,27 @@ type DetectionResult struct {
5760
// FormatDetector implements automatic format detection with confidence scoring
5861
type FormatDetector struct {
5962
// Pre-compiled regular expressions for performance
60-
harPattern *regexp.Regexp
61-
curlPattern *regexp.Regexp
62-
postmanPattern *regexp.Regexp
63-
yamlPattern *regexp.Regexp
63+
harPattern *regexp.Regexp
64+
curlPattern *regexp.Regexp
65+
postmanPattern *regexp.Regexp
66+
yamlPattern *regexp.Regexp
67+
swaggerPattern *regexp.Regexp
68+
openapi3Pattern *regexp.Regexp
69+
yamlSwaggerPat *regexp.Regexp
70+
yamlOpenapi3Pat *regexp.Regexp
6471
}
6572

6673
// NewFormatDetector creates a new format detector with compiled patterns
6774
func NewFormatDetector() *FormatDetector {
6875
return &FormatDetector{
69-
harPattern: regexp.MustCompile(`^\s*\{?\s*"?log"?[\s\S]*"?entries"?[\s\S]*\}?\s*$`),
70-
curlPattern: regexp.MustCompile(`(?i)^\s*curl\s+`),
71-
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"`),
72-
yamlPattern: regexp.MustCompile(`(?i)^\s*flows?\s*:`),
76+
harPattern: regexp.MustCompile(`^\s*\{?\s*"?log"?[\s\S]*"?entries"?[\s\S]*\}?\s*$`),
77+
curlPattern: regexp.MustCompile(`(?i)^\s*curl\s+`),
78+
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"`),
79+
yamlPattern: regexp.MustCompile(`(?i)^\s*flows?\s*:`),
80+
swaggerPattern: regexp.MustCompile(`(?i)"swagger"\s*:\s*"2\.\d+"`),
81+
openapi3Pattern: regexp.MustCompile(`(?i)"openapi"\s*:\s*"3\.\d+\.\d+"`),
82+
yamlSwaggerPat: regexp.MustCompile(`(?im)^swagger\s*:\s*["']?2\.\d+`),
83+
yamlOpenapi3Pat: regexp.MustCompile(`(?im)^openapi\s*:\s*["']?3\.\d+`),
7384
}
7485
}
7586

@@ -91,6 +102,7 @@ func (fd *FormatDetector) DetectFormat(data []byte) *DetectionResult {
91102
results := []*DetectionResult{
92103
fd.detectHAR(trimmed),
93104
fd.detectPostman(trimmed),
105+
fd.detectOpenAPI(trimmed),
94106
fd.detectCURL(trimmed),
95107
fd.detectYAML(trimmed),
96108
fd.detectJSON(trimmed),
@@ -403,6 +415,91 @@ func (fd *FormatDetector) detectYAML(content string) *DetectionResult {
403415
}
404416
}
405417

418+
// detectOpenAPI detects OpenAPI/Swagger spec format with confidence scoring.
419+
// Supports both Swagger 2.0 ("swagger": "2.0") and OpenAPI 3.x ("openapi": "3.x.x").
420+
func (fd *FormatDetector) detectOpenAPI(content string) *DetectionResult {
421+
confidence := 0.0
422+
reason := ""
423+
424+
// Check for Swagger 2.0 pattern (JSON)
425+
if fd.swaggerPattern.MatchString(content) {
426+
confidence += 0.9
427+
reason += "Swagger 2.0 spec detected; "
428+
}
429+
430+
// Check for OpenAPI 3.x pattern (JSON)
431+
if fd.openapi3Pattern.MatchString(content) {
432+
confidence += 0.9
433+
reason += "OpenAPI 3.x spec detected; "
434+
}
435+
436+
// Check for YAML-format Swagger 2.0
437+
if fd.yamlSwaggerPat.MatchString(content) {
438+
confidence += 0.9
439+
reason += "Swagger 2.0 YAML spec detected; "
440+
}
441+
442+
// Check for YAML-format OpenAPI 3.x
443+
if fd.yamlOpenapi3Pat.MatchString(content) {
444+
confidence += 0.9
445+
reason += "OpenAPI 3.x YAML spec detected; "
446+
}
447+
448+
// Look for paths field (key indicator of OpenAPI/Swagger)
449+
if strings.Contains(content, `"paths"`) || strings.Contains(content, "paths:") {
450+
confidence += 0.2
451+
reason += "paths field found; "
452+
}
453+
454+
// Look for info field
455+
if strings.Contains(content, `"info"`) || strings.Contains(content, "info:") {
456+
confidence += 0.1
457+
reason += "info field found; "
458+
}
459+
460+
// Validate it's valid JSON or YAML
461+
var jsonData map[string]interface{}
462+
if err := json.Unmarshal([]byte(content), &jsonData); err == nil {
463+
confidence += 0.1
464+
reason += ReasonValidJSON
465+
} else {
466+
// Try YAML
467+
var yamlData map[string]interface{}
468+
if err := yaml.Unmarshal([]byte(content), &yamlData); err == nil {
469+
confidence += 0.1
470+
reason += "Valid YAML; "
471+
}
472+
}
473+
474+
confidence = max(min(confidence, 1.0), 0)
475+
476+
return &DetectionResult{
477+
Format: FormatOpenAPI,
478+
Confidence: confidence,
479+
Reason: strings.TrimSpace(reason),
480+
}
481+
}
482+
483+
// validateOpenAPI validates OpenAPI/Swagger format specifically
484+
func (fd *FormatDetector) validateOpenAPI(data []byte) error {
485+
// Try JSON first, then YAML
486+
var raw map[string]interface{}
487+
if err := json.Unmarshal(data, &raw); err != nil {
488+
if err := yaml.Unmarshal(data, &raw); err != nil {
489+
return fmt.Errorf("invalid OpenAPI spec: not valid JSON or YAML: %w", err)
490+
}
491+
}
492+
493+
// Must have either "swagger" or "openapi" field
494+
_, hasSwagger := raw["swagger"]
495+
_, hasOpenAPI := raw["openapi"]
496+
if !hasSwagger && !hasOpenAPI {
497+
return fmt.Errorf("missing 'swagger' or 'openapi' version field")
498+
}
499+
500+
return nil
501+
}
502+
406503
// detectJSON detects generic JSON format with confidence scoring
407504
func (fd *FormatDetector) detectJSON(content string) *DetectionResult {
408505
confidence := 0.0
@@ -486,6 +583,8 @@ func (fd *FormatDetector) ValidateFormat(data []byte, format Format) error {
486583
return fd.validateYAML(data)
487584
case FormatJSON:
488585
return fd.validateJSON(data)
586+
case FormatOpenAPI:
587+
return fd.validateOpenAPI(data)
489588
default:
490589
return fmt.Errorf("unknown format: %v", format)
491590
}

‎packages/server/internal/api/rimportv2/service.go‎

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ type ImportConstraints struct {
125125
func DefaultConstraints() *ImportConstraints {
126126
return &ImportConstraints{
127127
MaxDataSizeBytes: 50 * 1024 * 1024, // 50MB
128-
SupportedFormats: []Format{FormatHAR, FormatYAML, FormatJSON, FormatCURL, FormatPostman},
128+
SupportedFormats: []Format{FormatHAR, FormatYAML, FormatJSON, FormatCURL, FormatPostman, FormatOpenAPI},
129129
AllowedMimeTypes: []string{
130130
"application/json",
131131
"application/har",
@@ -218,6 +218,13 @@ func WithLogger(logger *slog.Logger) ServiceOption {
218218
}
219219
}
220220

221+
// WithURLFetcher sets a custom URL fetcher (useful for testing)
222+
func WithURLFetcher(fetcher URLFetcher) ServiceOption {
223+
return func(s *Service) {
224+
s.urlFetcher = fetcher
225+
}
226+
}
227+
221228
// WithHTTPService sets the HTTP service for the service (required for HAR import overwrite detection)
222229
func WithHTTPService(httpService *shttp.HTTPService) ServiceOption {
223230
return func(s *Service) {
@@ -231,6 +238,7 @@ type Service struct {
231238
importer Importer
232239
validator Validator
233240
translatorRegistry *TranslatorRegistry
241+
urlFetcher URLFetcher
234242
logger *slog.Logger
235243
timeout time.Duration
236244
}
@@ -244,6 +252,7 @@ func NewService(importer Importer, validator Validator, opts ...ServiceOption) *
244252
importer: importer,
245253
validator: validator,
246254
translatorRegistry: NewTranslatorRegistry(nil), // Auto-initialize translator registry without HTTP service (will be overridden if provided)
255+
urlFetcher: NewURLFetcher(),
247256
timeout: 30 * time.Minute, // Default timeout for import processing
248257
logger: slog.Default(), // Default logger
249258
}
@@ -461,7 +470,11 @@ func (s *Service) ImportWithTextData(ctx context.Context, req *ImportRequest) (*
461470
return s.Import(ctx, req)
462471
}
463472

464-
// ImportUnified processes any supported format with automatic detection
473+
// ImportUnified processes any supported format with automatic detection.
474+
// It handles three input modes:
475+
// 1. Binary data (req.Data) - file uploads (Postman JSON, HAR, etc.)
476+
// 2. Text data (req.TextData) - pasted curl commands, raw JSON/YAML
477+
// 3. URL text (req.TextData is a URL) - fetches content from URL (e.g., swagger.json URL)
465478
func (s *Service) ImportUnified(ctx context.Context, req *ImportRequest) (*ImportResults, error) {
466479
s.logger.Debug("ImportUnified: Starting", "workspace_id", req.WorkspaceID)
467480
// Check if context is already cancelled
@@ -490,6 +503,11 @@ func (s *Service) ImportUnified(ctx context.Context, req *ImportRequest) (*Impor
490503
return nil, err // Return the original error for workspace access issues
491504
}
492505

506+
// Resolve input data: handle TextData and URL fetching
507+
if err := s.resolveInputData(ctx, req); err != nil {
508+
return nil, err
509+
}
510+
493511
s.logger.Debug("ImportUnified: Translating data")
494512
// Detect format and translate data
495513
translationResult, err := s.importer.ImportAndStoreUnified(ctx, req.Data, req.WorkspaceID)
@@ -689,16 +707,51 @@ func (s *Service) ImportUnifiedWithTextData(ctx context.Context, req *ImportRequ
689707
"has_text_data", len(req.TextData) > 0,
690708
"has_binary_data", len(req.Data) > 0)
691709

692-
// Convert text data to bytes if provided
693-
if len(req.Data) == 0 && req.TextData != "" {
694-
req.Data = []byte(req.TextData)
695-
s.logger.Debug("Converted text data to binary",
696-
"workspace_id", req.WorkspaceID,
697-
"original_length", len(req.TextData),
698-
"converted_length", len(req.Data))
710+
return s.ImportUnified(ctx, req)
711+
}
712+
713+
// resolveInputData ensures req.Data is populated for format detection.
714+
// It handles three cases:
715+
// 1. Data already present (file upload) - no action needed
716+
// 2. TextData is a URL - fetch content from the URL
717+
// 3. TextData is raw text (curl, JSON, YAML) - convert to bytes
718+
func (s *Service) resolveInputData(ctx context.Context, req *ImportRequest) error {
719+
if len(req.Data) > 0 {
720+
return nil // Already have binary data from file upload
699721
}
700722

701-
return s.ImportUnified(ctx, req)
723+
if req.TextData == "" {
724+
return nil // No text data either (validation should have caught this)
725+
}
726+
727+
// Check if TextData is a URL that we should fetch content from
728+
if IsURL(req.TextData) {
729+
s.logger.Debug("TextData is a URL, fetching content",
730+
"url", req.TextData,
731+
"workspace_id", req.WorkspaceID)
732+
733+
data, err := s.urlFetcher.Fetch(ctx, req.TextData)
734+
if err != nil {
735+
return fmt.Errorf("failed to fetch URL %q: %w", req.TextData, err)
736+
}
737+
738+
req.Data = data
739+
if req.Name == "" {
740+
req.Name = req.TextData
741+
}
742+
s.logger.Debug("URL content fetched successfully",
743+
"url", req.TextData,
744+
"data_size", len(data))
745+
return nil
746+
}
747+
748+
// TextData is raw text (curl command, JSON, YAML, etc.)
749+
req.Data = []byte(req.TextData)
750+
s.logger.Debug("Converted text data to binary",
751+
"workspace_id", req.WorkspaceID,
752+
"data_size", len(req.Data))
753+
754+
return nil
702755
}
703756

704757
// DetectFormat performs format detection on the provided data

‎packages/server/internal/api/rimportv2/translators.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/the-dev-tools/dev-tools/packages/server/pkg/service/shttp"
1919
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/harv2"
2020
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/tcurlv2"
21+
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/topenapiv2"
2122
"github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/tpostmanv2"
2223
yamlflowsimplev2 "github.com/the-dev-tools/dev-tools/packages/server/pkg/translate/yamlflowsimplev2"
2324
)
@@ -90,6 +91,7 @@ func NewTranslatorRegistry(httpService *shttp.HTTPService) *TranslatorRegistry {
9091
registry.RegisterTranslator(NewYAMLTranslator())
9192
registry.RegisterTranslator(NewCURLTranslator())
9293
registry.RegisterTranslator(NewPostmanTranslator())
94+
registry.RegisterTranslator(NewOpenAPITranslator())
9395
registry.RegisterTranslator(NewJSONTranslator())
9496

9597
return registry
@@ -407,6 +409,56 @@ func (t *PostmanTranslator) Translate(ctx context.Context, data []byte, workspac
407409
return result, nil
408410
}
409411

412+
// OpenAPITranslator implements Translator for OpenAPI/Swagger spec format
413+
type OpenAPITranslator struct {
414+
detector *FormatDetector
415+
}
416+
417+
// NewOpenAPITranslator creates a new OpenAPI translator
418+
func NewOpenAPITranslator() *OpenAPITranslator {
419+
return &OpenAPITranslator{
420+
detector: NewFormatDetector(),
421+
}
422+
}
423+
424+
func (t *OpenAPITranslator) GetFormat() Format {
425+
return FormatOpenAPI
426+
}
427+
428+
func (t *OpenAPITranslator) Validate(data []byte) error {
429+
return t.detector.ValidateFormat(data, FormatOpenAPI)
430+
}
431+
432+
func (t *OpenAPITranslator) Translate(ctx context.Context, data []byte, workspaceID idwrap.IDWrap) (*TranslationResult, error) {
433+
opts := topenapiv2.ConvertOptions{
434+
WorkspaceID: workspaceID,
435+
}
436+
437+
resolved, err := topenapiv2.ConvertOpenAPI(data, opts)
438+
if err != nil {
439+
return nil, fmt.Errorf("failed to convert OpenAPI spec: %w", err)
440+
}
441+
442+
result := &TranslationResult{
443+
HTTPRequests: resolved.HTTPRequests,
444+
Files: resolved.Files,
445+
Headers: resolved.Headers,
446+
SearchParams: resolved.SearchParams,
447+
BodyRaw: resolved.BodyRaw,
448+
Asserts: resolved.Asserts,
449+
Flows: []mflow.Flow{resolved.Flow},
450+
Nodes: resolved.Nodes,
451+
RequestNodes: resolved.RequestNodes,
452+
Edges: resolved.Edges,
453+
ProcessedAt: time.Now().UnixMilli(),
454+
}
455+
456+
// Extract domains from HTTP requests
457+
result.Domains = extractDomainsFromHTTP(result.HTTPRequests)
458+
459+
return result, nil
460+
}
461+
410462
// JSONTranslator implements Translator for generic JSON format
411463
type JSONTranslator struct {
412464
detector *FormatDetector

0 commit comments

Comments
 (0)