Skip to content

Commit 2660693

Browse files
dionesiusapclaude
andcommitted
feat: normalize relative paths and enforce ServiceUrl as constant
Two improvements for Studio Pro compatibility: 1. **Normalize relative paths to absolute file:// URLs** - Relative paths (./path or path/file.xml) are automatically converted to absolute file:// URLs in the Mendix model - Ensures Studio Pro can detect local file vs HTTP metadata sources - Example: './metadata.xml' → 'file:///absolute/path/to/project/metadata.xml' 2. **Enforce ServiceUrl as constant reference** - ServiceUrl must always start with '@' (e.g., '@Module.ConstantName') - Direct URLs are rejected with clear error message - Enforces Mendix best practice of externalizing configuration Implementation: - Added normalizeMetadataUrl() function with path resolution logic - Added validation in createODataClient() for ServiceUrl format - Updated all documentation and examples - Added comprehensive test coverage - Added test MPK file with real Studio Pro-created services Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4ad2cfe commit 2660693

6 files changed

Lines changed: 236 additions & 26 deletions

File tree

.claude/skills/mendix/odata-data-sharing.md

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@ This skill covers how to use OData services to share data between Mendix applica
1616

1717
`CREATE ODATA CLIENT` supports three formats for the `MetadataUrl` parameter:
1818

19-
| Format | Example | Use Case |
20-
|--------|---------|----------|
21-
| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Production, fetches from live service |
22-
| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Local metadata at fixed path |
23-
| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | Metadata alongside project (offline, testing, CI/CD) |
24-
25-
**Path Resolution:**
19+
| Format | Example | Stored In Model |
20+
|--------|---------|-----------------|
21+
| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Unchanged |
22+
| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Unchanged |
23+
| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | **Normalized to absolute `file://`** |
24+
25+
**Path Normalization:**
26+
- Relative paths (with or without `./`) are **automatically converted** to absolute `file://` URLs in the Mendix model
27+
- This ensures Studio Pro can properly detect local file vs HTTP metadata sources (radio button in UI)
28+
- Example: `./metadata/service.xml``file:///absolute/path/to/project/metadata/service.xml`
29+
30+
**Path Resolution (before normalization):**
2631
- With project loaded (`-p` flag or REPL): relative paths are resolved against the `.mpr` file's directory
2732
- Without project: relative paths are resolved against the current working directory
2833

@@ -33,6 +38,34 @@ This skill covers how to use OData services to share data between Mendix applica
3338
- **Pre-production** — test against upcoming API changes before deployment
3439
- **Firewall-friendly** — works in locked-down corporate environments
3540

41+
## ServiceUrl Must Be a Constant
42+
43+
**IMPORTANT:** The `ServiceUrl` parameter **must always be a constant reference** (prefixed with `@`). Direct URLs are not allowed.
44+
45+
**Correct:**
46+
```sql
47+
CREATE CONSTANT ProductClient.ProductDataApiLocation
48+
TYPE String
49+
DEFAULT 'http://localhost:8080/odata/productdataapi/v1/';
50+
51+
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
52+
ODataVersion: OData4,
53+
MetadataUrl: 'https://api.example.com/$metadata',
54+
ServiceUrl: '@ProductClient.ProductDataApiLocation' -- ✅ Constant reference
55+
);
56+
```
57+
58+
**Incorrect:**
59+
```sql
60+
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
61+
ODataVersion: OData4,
62+
MetadataUrl: 'https://api.example.com/$metadata',
63+
ServiceUrl: 'https://api.example.com/odata' -- ❌ Direct URL not allowed
64+
);
65+
```
66+
67+
This enforces Mendix best practice of externalizing configuration values for different environments.
68+
3669
## Architecture Overview
3770

3871
OData data sharing follows a **producer/consumer** pattern with three layers:

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,25 @@ CREATE ODATA CLIENT MyModule.LocalService (
149149
Timeout: 300
150150
);
151151

152-
-- Local file with relative path (resolved against .mpr directory)
152+
-- Local file with relative path (normalized to absolute file:// in model)
153153
CREATE ODATA CLIENT MyModule.LocalService2 (
154154
Version: '1.0',
155155
ODataVersion: OData4,
156156
MetadataUrl: './metadata/service.xml',
157-
Timeout: 300
157+
Timeout: 300,
158+
ServiceUrl: '@MyModule.ServiceLocation' -- Must be a constant reference
158159
);
159160
```
160161

161162
**Note:** `MetadataUrl` supports three formats:
162163
- `https://...` or `http://...` — fetches from HTTP(S) endpoint
163164
- `file:///abs/path` — reads from local absolute path
164-
- `./path` or `path/file.xml` — reads from local relative path (resolved against `.mpr` directory when project is loaded, or `cwd` otherwise)
165+
- `./path` or `path/file.xml` — reads from local relative path, **normalized to absolute `file://` in the model** for Studio Pro compatibility
166+
167+
**Important:** `ServiceUrl` must always be a constant reference starting with `@` (e.g., `@Module.ConstantName`). Create a constant first:
168+
```sql
169+
CREATE CONSTANT MyModule.ServiceLocation TYPE String DEFAULT 'https://api.example.com/odata/v4/';
170+
```
165171

166172
**OData Service Example:**
167173
```sql

mdl-examples/odata-local-metadata/README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ This example demonstrates how to create consumed OData services using local meta
99
- **Version-pinned metadata** — lock to a specific metadata version
1010
- **Pre-production services** — test against metadata files before deployment
1111

12+
## Important Notes
13+
14+
1. **Relative paths are normalized** — Any relative path is automatically converted to an absolute `file://` URL in the Mendix model for Studio Pro compatibility
15+
2. **ServiceUrl must be a constant** — Always use `@Module.ConstantName` format, not direct URLs
16+
1217
## Supported Formats
1318

1419
### 1. Absolute `file://` URI
@@ -20,13 +25,16 @@ CREATE ODATA CLIENT MyModule.Service (
2025

2126
### 2. Relative path (with or without `./`)
2227
```mdl
23-
-- Resolved relative to the .mpr file's directory when project is loaded
28+
-- Resolved relative to the .mpr file's directory, then normalized to absolute file://
29+
-- Example: './metadata/service.xml' → 'file:///absolute/path/to/project/metadata/service.xml'
2430
CREATE ODATA CLIENT MyModule.Service (
25-
MetadataUrl: './metadata/service.xml'
31+
MetadataUrl: './metadata/service.xml',
32+
ServiceUrl: '@MyModule.ServiceLocation'
2633
);
2734
2835
CREATE ODATA CLIENT MyModule.Service2 (
29-
MetadataUrl: 'metadata/service.xml'
36+
MetadataUrl: 'metadata/service.xml',
37+
ServiceUrl: '@MyModule.ServiceLocation'
3038
);
3139
```
3240

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,43 @@
11
-- Example: Using local OData metadata files
22
--
33
-- This demonstrates three ways to specify local metadata files for OData clients:
4-
-- 1. Absolute file:// URI
5-
-- 2. Relative path with ./
6-
-- 3. Relative path without ./
4+
-- 1. Absolute file:// URI (stored as-is)
5+
-- 2. Relative path with ./ (normalized to absolute file://)
6+
-- 3. Relative path without ./ (normalized to absolute file://)
7+
--
8+
-- IMPORTANT: ServiceUrl must be a constant reference (prefixed with @)
9+
10+
-- First, create constants for service locations (required for ServiceUrl)
11+
CREATE CONSTANT TestModule.NorthwindLocation
12+
TYPE String
13+
DEFAULT 'https://services.odata.org/V4/Northwind/Northwind.svc/';
714

8-
-- Method 1: Absolute file:// URI (works anywhere)
15+
-- Method 1: Absolute file:// URI (stored as-is in model)
916
-- Useful when the metadata file is in a fixed location
1017
CREATE ODATA CLIENT TestModule.NorthwindAbsolute (
11-
MetadataUrl: 'file:///tmp/northwind-metadata.xml'
18+
MetadataUrl: 'file:///tmp/northwind-metadata.xml',
19+
ServiceUrl: '@TestModule.NorthwindLocation'
1220
);
1321

14-
-- Method 2: Relative path with ./ (resolved against .mpr directory)
22+
-- Method 2: Relative path with ./ (normalized to absolute file:// in model)
1523
-- Best for metadata files stored alongside the project
24+
-- Example: './metadata/file.xml' → 'file:///absolute/path/to/project/metadata/file.xml'
1625
CREATE ODATA CLIENT TestModule.NorthwindRelative (
17-
MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml'
26+
MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml',
27+
ServiceUrl: '@TestModule.NorthwindLocation'
1828
);
1929

20-
-- Method 3: Relative path without ./ (also resolved against .mpr directory)
30+
-- Method 3: Relative path without ./ (normalized to absolute file:// in model)
2131
CREATE ODATA CLIENT TestModule.NorthwindSimple (
22-
MetadataUrl: 'mdl-examples/odata-local-metadata/sample-metadata.xml'
32+
MetadataUrl: 'mdl-examples/odata-local-metadata/sample-metadata.xml',
33+
ServiceUrl: '@TestModule.NorthwindLocation'
2334
);
2435

25-
-- HTTP(S) URLs still work as before
36+
-- HTTP(S) URLs still work as before (stored as-is in model)
2637
CREATE ODATA CLIENT TestModule.NorthwindRemote (
27-
MetadataUrl: 'https://services.odata.org/V4/Northwind/Northwind.svc/$metadata'
38+
MetadataUrl: 'https://services.odata.org/V4/Northwind/Northwind.svc/$metadata',
39+
ServiceUrl: '@TestModule.NorthwindLocation'
2840
);
2941

30-
-- Show the created clients
42+
-- Show the created clients (note: relative paths appear as file:// URLs)
3143
SHOW ODATA CLIENTS IN TestModule;

mdl/executor/cmd_odata.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,10 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error {
991991
ClientCertificate: stmt.ClientCertificate,
992992
}
993993
if stmt.ServiceUrl != "" {
994+
// ServiceUrl must be a constant reference (e.g., @Module.ConstantName)
995+
if !strings.HasPrefix(stmt.ServiceUrl, "@") {
996+
return fmt.Errorf("ServiceUrl must be a constant reference starting with '@' (e.g., '@Module.LocationConstant'), got: %s", stmt.ServiceUrl)
997+
}
994998
cfg.OverrideLocation = true
995999
cfg.CustomLocation = stmt.ServiceUrl
9961000
}
@@ -1004,12 +1008,21 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error {
10041008
}
10051009

10061010
// Fetch and cache $metadata from the service URL
1011+
// Normalize local file paths to absolute file:// URLs for Studio Pro compatibility
10071012
if newSvc.MetadataUrl != "" {
10081013
mprDir := ""
10091014
if e.mprPath != "" {
10101015
mprDir = filepath.Dir(e.mprPath)
10111016
}
1012-
metadata, hash, err := fetchODataMetadata(newSvc.MetadataUrl, mprDir)
1017+
1018+
// Normalize MetadataUrl: convert relative paths to absolute file:// URLs
1019+
normalizedUrl, err := normalizeMetadataUrl(newSvc.MetadataUrl, mprDir)
1020+
if err != nil {
1021+
return fmt.Errorf("failed to normalize MetadataUrl: %w", err)
1022+
}
1023+
newSvc.MetadataUrl = normalizedUrl
1024+
1025+
metadata, hash, err := fetchODataMetadata(normalizedUrl, mprDir)
10131026
if err != nil {
10141027
fmt.Fprintf(e.output, "Warning: could not fetch $metadata: %v\n", err)
10151028
} else if metadata != "" {
@@ -1411,6 +1424,59 @@ func astEntityDefToModel(def *ast.PublishedEntityDef) (*model.PublishedEntityTyp
14111424
return entityType, entitySet
14121425
}
14131426

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+
14141480
// fetchODataMetadata downloads or reads the $metadata document.
14151481
// Supports:
14161482
// - https://... or http://... (HTTP fetch)

mdl/executor/cmd_odata_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,91 @@ func TestFetchODataMetadata_LocalFile(t *testing.T) {
9393
}
9494
}
9595

96+
func TestNormalizeMetadataUrl(t *testing.T) {
97+
tmpDir := t.TempDir()
98+
99+
tests := []struct {
100+
name string
101+
input string
102+
mprDir string
103+
wantPrefix string
104+
wantErr bool
105+
}{
106+
{
107+
name: "HTTP URL unchanged",
108+
input: "https://api.example.com/$metadata",
109+
mprDir: "",
110+
wantPrefix: "https://",
111+
wantErr: false,
112+
},
113+
{
114+
name: "HTTPS URL unchanged",
115+
input: "http://localhost:8080/odata/$metadata",
116+
mprDir: "",
117+
wantPrefix: "http://",
118+
wantErr: false,
119+
},
120+
{
121+
name: "Absolute file:// unchanged",
122+
input: "file:///tmp/metadata.xml",
123+
mprDir: "",
124+
wantPrefix: "file://",
125+
wantErr: false,
126+
},
127+
{
128+
name: "Relative path normalized to file://",
129+
input: "./metadata.xml",
130+
mprDir: tmpDir,
131+
wantPrefix: "file://",
132+
wantErr: false,
133+
},
134+
{
135+
name: "Bare relative path normalized to file://",
136+
input: "metadata.xml",
137+
mprDir: tmpDir,
138+
wantPrefix: "file://",
139+
wantErr: false,
140+
},
141+
{
142+
name: "Absolute path normalized to file://",
143+
input: "/tmp/metadata.xml",
144+
mprDir: "",
145+
wantPrefix: "file://",
146+
wantErr: false,
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
result, err := normalizeMetadataUrl(tt.input, tt.mprDir)
153+
154+
if tt.wantErr {
155+
if err == nil {
156+
t.Errorf("Expected error but got none")
157+
}
158+
return
159+
}
160+
161+
if err != nil {
162+
t.Errorf("Unexpected error: %v", err)
163+
return
164+
}
165+
166+
if !strings.HasPrefix(result, tt.wantPrefix) {
167+
t.Errorf("Result %q does not start with %q", result, tt.wantPrefix)
168+
}
169+
170+
// Verify file:// URLs are absolute
171+
if strings.HasPrefix(result, "file://") {
172+
path := strings.TrimPrefix(result, "file://")
173+
if !filepath.IsAbs(path) {
174+
t.Errorf("file:// URL contains relative path: %q", result)
175+
}
176+
}
177+
})
178+
}
179+
}
180+
96181
func TestFetchODataMetadata_RelativePathWithoutProject(t *testing.T) {
97182
// Create metadata file in current directory
98183
tmpDir := t.TempDir()

0 commit comments

Comments
 (0)