Skip to content

Commit 8cd529f

Browse files
authored
Merge pull request #210 from dionesiusap/feature/206-odata-local-metadata
feat: Support local file metadata for OData clients
2 parents 3df5a5f + a180137 commit 8cd529f

12 files changed

Lines changed: 909 additions & 35 deletions

File tree

.claude/skills/mendix/browse-integrations.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ show external actions;
4040

4141
## Contract Browsing: OData $metadata
4242

43+
`create odata client` auto-fetches and caches the `$metadata` XML from HTTP(S) URLs or reads it from local files. Browse it without network access:
44+
45+
**Note:** `MetadataUrl` supports:
46+
- `https://...` or `http://...` — fetches from HTTP endpoint
47+
- `file:///abs/path` — reads from local absolute path
48+
- `./path` or `path/file.xml` — reads from local relative path (resolved against `.mpr` directory)
49+
50+
Local metadata files enable offline development, reproducible testing, and version-pinned contracts.
4351
`create odata client` auto-fetches and caches the `$metadata` XML. Browse it without network access:
4452

4553
```sql

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

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,61 @@ This skill covers how to use OData services to share data between Mendix applica
1010
- User asks about external entities, consumed/published OData services
1111
- User wants to decouple modules or apps for independent deployment
1212
- User asks about the view entity pattern for OData services
13+
- User asks about local metadata files or offline OData development
14+
15+
## MetadataUrl Formats
16+
17+
`CREATE ODATA CLIENT` supports three formats for the `MetadataUrl` parameter:
18+
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):**
31+
- With project loaded (`-p` flag or REPL): relative paths are resolved against the `.mpr` file's directory
32+
- Without project: relative paths are resolved against the current working directory
33+
34+
**Use Cases for Local Metadata:**
35+
- **Offline development** — no network access required
36+
- **Testing and CI/CD** — reproducible builds with metadata snapshots
37+
- **Version control** — commit metadata files alongside code
38+
- **Pre-production** — test against upcoming API changes before deployment
39+
- **Firewall-friendly** — works in locked-down corporate environments
40+
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.
1368

1469
## Architecture Overview
1570

@@ -234,6 +289,40 @@ create odata client ProductClient.ProductDataApiClient (
234289
HttpPassword: '1'
235290
);
236291

292+
-- OData client with local file - relative path (offline development)
293+
-- Resolved relative to .mpr directory when project is loaded
294+
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
295+
ODataVersion: OData4,
296+
MetadataUrl: './metadata/productdataapi.xml',
297+
Timeout: 300,
298+
ServiceUrl: '@ProductClient.ProductDataApiLocation',
299+
UseAuthentication: Yes,
300+
HttpUsername: 'MxAdmin',
301+
HttpPassword: '1'
302+
);
303+
304+
-- OData client with local file - relative path without ./
305+
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
306+
ODataVersion: OData4,
307+
MetadataUrl: 'metadata/productdataapi.xml',
308+
Timeout: 300,
309+
ServiceUrl: '@ProductClient.ProductDataApiLocation',
310+
UseAuthentication: Yes,
311+
HttpUsername: 'MxAdmin',
312+
HttpPassword: '1'
313+
);
314+
315+
-- OData client with local file - absolute file:// URI
316+
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
317+
ODataVersion: OData4,
318+
MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml',
319+
Timeout: 300,
320+
ServiceUrl: '@ProductClient.ProductDataApiLocation',
321+
UseAuthentication: Yes,
322+
HttpUsername: 'MxAdmin',
323+
HttpPassword: '1'
324+
);
325+
237326
-- External entities (mapped from published service)
238327
create external entity ProductClient.ProductsEE
239328
from odata client ProductClient.ProductDataApiClient
@@ -460,13 +549,40 @@ authentication basic
460549

461550
## Folder Organization
462551

463-
Use the `folder` property to organize OData documents within modules:
552+
Use the `Folder` property to organize OData documents within modules.
553+
554+
**MetadataUrl accepts three formats:**
555+
1. **HTTP(S) URL** — fetches from remote service (production)
556+
2. **file:///absolute/path** — reads from local absolute path
557+
3. **./path or path/file.xml** — reads from local relative path (resolved against .mpr directory)
464558

465559
```sql
560+
-- Format 1: HTTP(S) URL
466561
create odata client ProductClient.ProductDataApiClient (
467562
ODataVersion: OData4,
468-
MetadataUrl: 'http://localhost:8080/odata/productdataapi/v1/$metadata',
469-
folder: 'Integration/ProductAPI'
563+
MetadataUrl: 'https://api.example.com/odata/v4/$metadata',
564+
Folder: 'Integration/ProductAPI'
565+
);
566+
567+
-- Format 2: Absolute file:// URI
568+
create odata client ProductClient.ProductDataApiClient (
569+
ODataVersion: OData4,
570+
MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml',
571+
Folder: 'Integration/ProductAPI'
572+
);
573+
574+
-- Format 3a: Relative path with ./
575+
create odata client ProductClient.ProductDataApiClient (
576+
ODataVersion: OData4,
577+
MetadataUrl: './metadata/productdataapi.xml',
578+
Folder: 'Integration/ProductAPI'
579+
);
580+
581+
-- Format 3b: Relative path without ./
582+
create odata client ProductClient.ProductDataApiClient (
583+
ODataVersion: OData4,
584+
MetadataUrl: 'metadata/productdataapi.xml',
585+
Folder: 'Integration/ProductAPI'
470586
);
471587

472588
create odata service ProductApi.ProductDataApi (
@@ -506,7 +622,11 @@ Before publishing:
506622

507623
Before consuming:
508624
- [ ] Location constant created for environment-specific URLs
509-
- [ ] OData client points to `$metadata` URL and uses `ServiceUrl: '@Module.Constant'`
625+
- [ ] OData client `MetadataUrl` points to either:
626+
- HTTP(S) URL: `https://api.example.com/$metadata`
627+
- Local file (absolute): `file:///path/to/metadata.xml`
628+
- Local file (relative): `./metadata/service.xml` (resolved against `.mpr` directory)
629+
- [ ] OData client uses `ServiceUrl: '@Module.Constant'` for runtime endpoint
510630
- [ ] External entities match the published exposed names and types
511631
- [ ] Module role created and granted on external entities (READ, optionally CREATE/WRITE/DELETE)
512632

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to mxcli will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- **Local file metadata for OData clients**`CREATE ODATA CLIENT` now supports `file://` URLs and relative paths for `MetadataUrl`, enabling offline development, reproducible testing, and version-pinned contracts (#206)
12+
- **Path normalization** — Relative paths in `MetadataUrl` are automatically converted to absolute `file://` URLs for Studio Pro compatibility
13+
- **ServiceUrl validation**`ServiceUrl` parameter must now be a constant reference (e.g., `@Module.ConstantName`) to enforce Mendix best practice
14+
- **Shared URL utilities**`internal/pathutil` package with `NormalizeURL()`, `URIToPath()`, and `PathFromURL()` for reuse across components
715
## [0.7.0] - 2026-04-21
816

917
### Added

cmd/mxcli/lsp_helpers.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import (
77
"context"
88
"encoding/json"
99
"fmt"
10-
"net/url"
1110
"os"
1211
"os/exec"
1312
"path/filepath"
1413
"strings"
1514
"time"
1615

16+
"github.com/mendixlabs/mxcli/internal/pathutil"
1717
"go.lsp.dev/protocol"
1818
)
1919

@@ -31,16 +31,9 @@ func (s stdioReadWriteCloser) Write(p []byte) (int, error) { return os.Stdout.Wr
3131
func (s stdioReadWriteCloser) Close() error { return nil }
3232

3333
// uriToPath converts a file:// URI to a filesystem path.
34+
// Deprecated: use pathutil.URIToPath instead.
3435
func uriToPath(rawURI string) string {
35-
u, err := url.Parse(rawURI)
36-
if err != nil {
37-
return ""
38-
}
39-
if u.Scheme == "file" {
40-
return filepath.FromSlash(u.Path)
41-
}
42-
// If no scheme, treat as a raw path
43-
return rawURI
36+
return pathutil.URIToPath(rawURI)
4437
}
4538

4639
// pullConfiguration requests the "mdl" configuration section from the client.

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,40 @@ create constant MyModule.EnableLogging type boolean default true;
133133

134134
**OData Client Example:**
135135
```sql
136+
-- HTTP(S) URL (fetches metadata from remote service)
136137
create odata client MyModule.ExternalAPI (
137-
version: '1.0',
138+
Version: '1.0',
138139
ODataVersion: OData4,
139140
MetadataUrl: 'https://api.example.com/odata/v4/$metadata',
140141
timeout: 300
141142
);
143+
144+
-- Local file with absolute file:// URI
145+
CREATE ODATA CLIENT MyModule.LocalService (
146+
Version: '1.0',
147+
ODataVersion: OData4,
148+
MetadataUrl: 'file:///path/to/metadata.xml',
149+
Timeout: 300
150+
);
151+
152+
-- Local file with relative path (normalized to absolute file:// in model)
153+
CREATE ODATA CLIENT MyModule.LocalService2 (
154+
Version: '1.0',
155+
ODataVersion: OData4,
156+
MetadataUrl: './metadata/service.xml',
157+
Timeout: 300,
158+
ServiceUrl: '@MyModule.ServiceLocation' -- Must be a constant reference
159+
);
160+
```
161+
162+
**Note:** `MetadataUrl` supports three formats:
163+
- `https://...` or `http://...` — fetches from HTTP(S) endpoint
164+
- `file:///abs/path` — reads from local absolute path
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/';
142170
```
143171

144172
**OData Service Example:**

internal/pathutil/uri.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package pathutil
4+
5+
import (
6+
"fmt"
7+
"net/url"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
)
12+
13+
// URIToPath converts a file:// URI to a filesystem path.
14+
// If the input is not a valid URI or has a scheme other than "file",
15+
// returns the input unchanged (treating it as a raw path).
16+
func URIToPath(rawURI string) string {
17+
u, err := url.Parse(rawURI)
18+
if err != nil {
19+
return ""
20+
}
21+
if u.Scheme == "file" {
22+
return filepath.FromSlash(u.Path)
23+
}
24+
// If no scheme, treat as a raw path
25+
return rawURI
26+
}
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+
// RFC 8089 requires three slashes: file:///path or file:///C:/path
82+
slashed := filepath.ToSlash(absPath)
83+
if !strings.HasPrefix(slashed, "/") {
84+
// Windows path like C:/Users/x needs leading slash: file:///C:/Users/x
85+
slashed = "/" + slashed
86+
}
87+
return "file://" + slashed, nil
88+
}
89+
90+
// PathFromURL extracts a filesystem path from a URL, handling both file:// URLs and HTTP(S) URLs.
91+
// For file:// URLs, returns the local filesystem path.
92+
// For HTTP(S) URLs or other schemes, returns an empty string.
93+
// This is the inverse of converting a path to a file:// URL.
94+
func PathFromURL(rawURL string) string {
95+
if strings.HasPrefix(rawURL, "file://") {
96+
return URIToPath(rawURL)
97+
}
98+
// Not a file:// URL
99+
return ""
100+
}

0 commit comments

Comments
 (0)