Skip to content

Commit cc64aa4

Browse files
dionesiusapclaude
andcommitted
refactor: move URL normalization to shared pathutil package
Extracted URL normalization logic into internal/pathutil for reuse across components (OData clients, REST clients with OpenAPI, etc.). Changes: - Added pathutil.NormalizeURL() - converts relative paths to absolute file:// URLs - Added pathutil.PathFromURL() - extracts filesystem path from file:// URLs - Removed duplicate normalizeMetadataUrl() from cmd_odata.go - Updated executor to use pathutil.NormalizeURL() - Added comprehensive tests for new functions Benefits: - Single source of truth for URL normalization logic - Reusable for REST clients with OpenAPI contracts - Better test coverage - Consistent behavior across all components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 2660693 commit cc64aa4

4 files changed

Lines changed: 222 additions & 62 deletions

File tree

internal/pathutil/uri.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
package pathutil
44

55
import (
6+
"fmt"
67
"net/url"
8+
"os"
79
"path/filepath"
10+
"strings"
811
)
912

1013
// URIToPath converts a file:// URI to a filesystem path.
@@ -21,3 +24,71 @@ func URIToPath(rawURI string) string {
2124
// If no scheme, treat as a raw path
2225
return rawURI
2326
}
27+
28+
// NormalizeURL converts relative paths to absolute file:// URLs, while preserving HTTP(S) URLs.
29+
// This is useful for storing URLs in a way that external tools (like Mendix Studio Pro) can
30+
// reliably distinguish between local files and HTTP endpoints.
31+
//
32+
// Supported input formats:
33+
// - https://... or http://... → returned as-is
34+
// - file:///abs/path → returned as-is
35+
// - ./path or path/file.xml → converted to file:///absolute/path
36+
//
37+
// If baseDir is provided, relative paths are resolved against it.
38+
// Otherwise, they're resolved against the current working directory.
39+
//
40+
// Returns an error if the path cannot be resolved to an absolute path.
41+
func NormalizeURL(rawURL string, baseDir string) (string, error) {
42+
if rawURL == "" {
43+
return "", nil
44+
}
45+
46+
// HTTP(S) URLs are already normalized
47+
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
48+
return rawURL, nil
49+
}
50+
51+
// Extract file path from file:// URLs or use raw input
52+
filePath := rawURL
53+
if strings.HasPrefix(rawURL, "file://") {
54+
filePath = URIToPath(rawURL)
55+
if filePath == "" {
56+
return "", fmt.Errorf("invalid file:// URI: %s", rawURL)
57+
}
58+
}
59+
60+
// Convert relative paths to absolute
61+
if !filepath.IsAbs(filePath) {
62+
if baseDir != "" {
63+
filePath = filepath.Join(baseDir, filePath)
64+
} else {
65+
// No base directory - use cwd
66+
cwd, err := os.Getwd()
67+
if err != nil {
68+
return "", fmt.Errorf("failed to resolve relative path: %w", err)
69+
}
70+
filePath = filepath.Join(cwd, filePath)
71+
}
72+
}
73+
74+
// Convert to absolute path (clean up ./ and ../)
75+
absPath, err := filepath.Abs(filePath)
76+
if err != nil {
77+
return "", fmt.Errorf("failed to get absolute path: %w", err)
78+
}
79+
80+
// Return as file:// URL with forward slashes (cross-platform)
81+
return "file://" + filepath.ToSlash(absPath), nil
82+
}
83+
84+
// PathFromURL extracts a filesystem path from a URL, handling both file:// URLs and HTTP(S) URLs.
85+
// For file:// URLs, returns the local filesystem path.
86+
// For HTTP(S) URLs or other schemes, returns an empty string.
87+
// This is the inverse of converting a path to a file:// URL.
88+
func PathFromURL(rawURL string) string {
89+
if strings.HasPrefix(rawURL, "file://") {
90+
return URIToPath(rawURL)
91+
}
92+
// Not a file:// URL
93+
return ""
94+
}

internal/pathutil/uri_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
package pathutil
44

55
import (
6+
"path/filepath"
67
"runtime"
8+
"strings"
79
"testing"
810
)
911

@@ -80,3 +82,141 @@ func TestURIToPath_Windows(t *testing.T) {
8082
})
8183
}
8284
}
85+
86+
func TestNormalizeURL(t *testing.T) {
87+
tmpDir := t.TempDir()
88+
89+
tests := []struct {
90+
name string
91+
input string
92+
baseDir string
93+
wantPrefix string
94+
wantErr bool
95+
}{
96+
{
97+
name: "HTTP URL unchanged",
98+
input: "https://api.example.com/$metadata",
99+
baseDir: "",
100+
wantPrefix: "https://",
101+
wantErr: false,
102+
},
103+
{
104+
name: "HTTPS URL unchanged",
105+
input: "http://localhost:8080/odata/$metadata",
106+
baseDir: "",
107+
wantPrefix: "http://",
108+
wantErr: false,
109+
},
110+
{
111+
name: "Absolute file:// URL unchanged",
112+
input: "file:///tmp/metadata.xml",
113+
baseDir: "",
114+
wantPrefix: "file://",
115+
wantErr: false,
116+
},
117+
{
118+
name: "Relative path with ./ normalized",
119+
input: "./metadata.xml",
120+
baseDir: tmpDir,
121+
wantPrefix: "file://",
122+
wantErr: false,
123+
},
124+
{
125+
name: "Bare relative path normalized",
126+
input: "metadata.xml",
127+
baseDir: tmpDir,
128+
wantPrefix: "file://",
129+
wantErr: false,
130+
},
131+
{
132+
name: "Absolute path normalized to file://",
133+
input: "/tmp/metadata.xml",
134+
baseDir: "",
135+
wantPrefix: "file://",
136+
wantErr: false,
137+
},
138+
{
139+
name: "Subdirectory relative path",
140+
input: "contracts/metadata.xml",
141+
baseDir: tmpDir,
142+
wantPrefix: "file://",
143+
wantErr: false,
144+
},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
result, err := NormalizeURL(tt.input, tt.baseDir)
150+
151+
if tt.wantErr {
152+
if err == nil {
153+
t.Errorf("Expected error but got none")
154+
}
155+
return
156+
}
157+
158+
if err != nil {
159+
t.Errorf("Unexpected error: %v", err)
160+
return
161+
}
162+
163+
if !strings.HasPrefix(result, tt.wantPrefix) {
164+
t.Errorf("Result %q does not start with %q", result, tt.wantPrefix)
165+
}
166+
167+
// Verify file:// URLs contain absolute paths
168+
if strings.HasPrefix(result, "file://") {
169+
path := strings.TrimPrefix(result, "file://")
170+
if !filepath.IsAbs(path) {
171+
t.Errorf("file:// URL contains relative path: %q", result)
172+
}
173+
}
174+
175+
// Verify relative paths are resolved correctly
176+
if tt.baseDir != "" && !strings.HasPrefix(tt.input, "http") && !strings.HasPrefix(tt.input, "file://") {
177+
path := strings.TrimPrefix(result, "file://")
178+
if !strings.Contains(path, filepath.ToSlash(tmpDir)) {
179+
t.Errorf("Relative path not resolved against baseDir. Got: %q", result)
180+
}
181+
}
182+
})
183+
}
184+
}
185+
186+
func TestPathFromURL(t *testing.T) {
187+
tests := []struct {
188+
name string
189+
input string
190+
expected string
191+
}{
192+
{
193+
name: "file:// URL extracts path",
194+
input: "file:///tmp/metadata.xml",
195+
expected: "/tmp/metadata.xml",
196+
},
197+
{
198+
name: "HTTP URL returns empty",
199+
input: "https://api.example.com/$metadata",
200+
expected: "",
201+
},
202+
{
203+
name: "HTTPS URL returns empty",
204+
input: "http://localhost:8080/metadata",
205+
expected: "",
206+
},
207+
{
208+
name: "bare path returns empty",
209+
input: "/tmp/file.xml",
210+
expected: "",
211+
},
212+
}
213+
214+
for _, tt := range tests {
215+
t.Run(tt.name, func(t *testing.T) {
216+
result := PathFromURL(tt.input)
217+
if runtime.GOOS != "windows" && result != tt.expected {
218+
t.Errorf("PathFromURL(%q) = %q, want %q", tt.input, result, tt.expected)
219+
}
220+
})
221+
}
222+
}

mdl/executor/cmd_odata.go

Lines changed: 1 addition & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,7 +1016,7 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error {
10161016
}
10171017

10181018
// Normalize MetadataUrl: convert relative paths to absolute file:// URLs
1019-
normalizedUrl, err := normalizeMetadataUrl(newSvc.MetadataUrl, mprDir)
1019+
normalizedUrl, err := pathutil.NormalizeURL(newSvc.MetadataUrl, mprDir)
10201020
if err != nil {
10211021
return fmt.Errorf("failed to normalize MetadataUrl: %w", err)
10221022
}
@@ -1424,59 +1424,6 @@ func astEntityDefToModel(def *ast.PublishedEntityDef) (*model.PublishedEntityTyp
14241424
return entityType, entitySet
14251425
}
14261426

1427-
// normalizeMetadataUrl converts relative paths to absolute file:// URLs.
1428-
// This ensures Studio Pro can properly detect local file vs HTTP metadata sources.
1429-
//
1430-
// Input formats:
1431-
// - https://... or http://... → returned as-is
1432-
// - file:///abs/path → returned as-is
1433-
// - ./path or path/file.xml → converted to file:///absolute/path
1434-
//
1435-
// If mprDir is provided, relative paths are resolved against it.
1436-
// Otherwise, they're resolved against the current working directory.
1437-
func normalizeMetadataUrl(metadataUrl string, mprDir string) (string, error) {
1438-
if metadataUrl == "" {
1439-
return "", nil
1440-
}
1441-
1442-
// HTTP(S) URLs are already normalized
1443-
if strings.HasPrefix(metadataUrl, "http://") || strings.HasPrefix(metadataUrl, "https://") {
1444-
return metadataUrl, nil
1445-
}
1446-
1447-
// Extract file path
1448-
filePath := metadataUrl
1449-
if strings.HasPrefix(metadataUrl, "file://") {
1450-
filePath = pathutil.URIToPath(metadataUrl)
1451-
if filePath == "" {
1452-
return "", fmt.Errorf("invalid file:// URI: %s", metadataUrl)
1453-
}
1454-
}
1455-
1456-
// Convert relative paths to absolute
1457-
if !filepath.IsAbs(filePath) {
1458-
if mprDir != "" {
1459-
filePath = filepath.Join(mprDir, filePath)
1460-
} else {
1461-
// No project loaded - use cwd
1462-
cwd, err := os.Getwd()
1463-
if err != nil {
1464-
return "", fmt.Errorf("failed to resolve relative path: %w", err)
1465-
}
1466-
filePath = filepath.Join(cwd, filePath)
1467-
}
1468-
}
1469-
1470-
// Convert to absolute path (clean up ./ and ../)
1471-
absPath, err := filepath.Abs(filePath)
1472-
if err != nil {
1473-
return "", fmt.Errorf("failed to get absolute path: %w", err)
1474-
}
1475-
1476-
// Return as file:// URL
1477-
return "file://" + filepath.ToSlash(absPath), nil
1478-
}
1479-
14801427
// fetchODataMetadata downloads or reads the $metadata document.
14811428
// Supports:
14821429
// - https://... or http://... (HTTP fetch)

mdl/executor/cmd_odata_test.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"strings"
99
"testing"
10+
11+
"github.com/mendixlabs/mxcli/internal/pathutil"
1012
)
1113

1214
func TestFetchODataMetadata_LocalFile(t *testing.T) {
@@ -99,57 +101,57 @@ func TestNormalizeMetadataUrl(t *testing.T) {
99101
tests := []struct {
100102
name string
101103
input string
102-
mprDir string
104+
baseDir string
103105
wantPrefix string
104106
wantErr bool
105107
}{
106108
{
107109
name: "HTTP URL unchanged",
108110
input: "https://api.example.com/$metadata",
109-
mprDir: "",
111+
baseDir: "",
110112
wantPrefix: "https://",
111113
wantErr: false,
112114
},
113115
{
114116
name: "HTTPS URL unchanged",
115117
input: "http://localhost:8080/odata/$metadata",
116-
mprDir: "",
118+
baseDir: "",
117119
wantPrefix: "http://",
118120
wantErr: false,
119121
},
120122
{
121123
name: "Absolute file:// unchanged",
122124
input: "file:///tmp/metadata.xml",
123-
mprDir: "",
125+
baseDir: "",
124126
wantPrefix: "file://",
125127
wantErr: false,
126128
},
127129
{
128130
name: "Relative path normalized to file://",
129131
input: "./metadata.xml",
130-
mprDir: tmpDir,
132+
baseDir: tmpDir,
131133
wantPrefix: "file://",
132134
wantErr: false,
133135
},
134136
{
135137
name: "Bare relative path normalized to file://",
136138
input: "metadata.xml",
137-
mprDir: tmpDir,
139+
baseDir: tmpDir,
138140
wantPrefix: "file://",
139141
wantErr: false,
140142
},
141143
{
142144
name: "Absolute path normalized to file://",
143145
input: "/tmp/metadata.xml",
144-
mprDir: "",
146+
baseDir: "",
145147
wantPrefix: "file://",
146148
wantErr: false,
147149
},
148150
}
149151

150152
for _, tt := range tests {
151153
t.Run(tt.name, func(t *testing.T) {
152-
result, err := normalizeMetadataUrl(tt.input, tt.mprDir)
154+
result, err := pathutil.NormalizeURL(tt.input, tt.baseDir)
153155

154156
if tt.wantErr {
155157
if err == nil {

0 commit comments

Comments
 (0)