diff --git a/.claude/skills/mendix/browse-integrations.md b/.claude/skills/mendix/browse-integrations.md
index dd695a0d..49ae431e 100644
--- a/.claude/skills/mendix/browse-integrations.md
+++ b/.claude/skills/mendix/browse-integrations.md
@@ -40,7 +40,14 @@ SHOW EXTERNAL ACTIONS;
## Contract Browsing: OData $metadata
-`CREATE ODATA CLIENT` auto-fetches and caches the `$metadata` XML. Browse it without network access:
+`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:
+
+**Note:** `MetadataUrl` supports:
+- `https://...` or `http://...` — fetches from HTTP endpoint
+- `file:///abs/path` — reads from local absolute path
+- `./path` or `path/file.xml` — reads from local relative path (resolved against `.mpr` directory)
+
+Local metadata files enable offline development, reproducible testing, and version-pinned contracts.
```sql
-- List all entity types from the contract
diff --git a/.claude/skills/mendix/odata-data-sharing.md b/.claude/skills/mendix/odata-data-sharing.md
index ddc01f33..bd471817 100644
--- a/.claude/skills/mendix/odata-data-sharing.md
+++ b/.claude/skills/mendix/odata-data-sharing.md
@@ -10,6 +10,61 @@ This skill covers how to use OData services to share data between Mendix applica
- User asks about external entities, consumed/published OData services
- User wants to decouple modules or apps for independent deployment
- User asks about the view entity pattern for OData services
+- User asks about local metadata files or offline OData development
+
+## MetadataUrl Formats
+
+`CREATE ODATA CLIENT` supports three formats for the `MetadataUrl` parameter:
+
+| Format | Example | Stored In Model |
+|--------|---------|-----------------|
+| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Unchanged |
+| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Unchanged |
+| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | **Normalized to absolute `file://`** |
+
+**Path Normalization:**
+- Relative paths (with or without `./`) are **automatically converted** to absolute `file://` URLs in the Mendix model
+- This ensures Studio Pro can properly detect local file vs HTTP metadata sources (radio button in UI)
+- Example: `./metadata/service.xml` → `file:///absolute/path/to/project/metadata/service.xml`
+
+**Path Resolution (before normalization):**
+- With project loaded (`-p` flag or REPL): relative paths are resolved against the `.mpr` file's directory
+- Without project: relative paths are resolved against the current working directory
+
+**Use Cases for Local Metadata:**
+- **Offline development** — no network access required
+- **Testing and CI/CD** — reproducible builds with metadata snapshots
+- **Version control** — commit metadata files alongside code
+- **Pre-production** — test against upcoming API changes before deployment
+- **Firewall-friendly** — works in locked-down corporate environments
+
+## ServiceUrl Must Be a Constant
+
+**IMPORTANT:** The `ServiceUrl` parameter **must always be a constant reference** (prefixed with `@`). Direct URLs are not allowed.
+
+**Correct:**
+```sql
+CREATE CONSTANT ProductClient.ProductDataApiLocation
+ TYPE String
+ DEFAULT 'http://localhost:8080/odata/productdataapi/v1/';
+
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: 'https://api.example.com/$metadata',
+ ServiceUrl: '@ProductClient.ProductDataApiLocation' -- ✅ Constant reference
+);
+```
+
+**Incorrect:**
+```sql
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: 'https://api.example.com/$metadata',
+ ServiceUrl: 'https://api.example.com/odata' -- ❌ Direct URL not allowed
+);
+```
+
+This enforces Mendix best practice of externalizing configuration values for different environments.
## Architecture Overview
@@ -223,7 +278,7 @@ CREATE CONSTANT ProductClient.ProductDataApiLocation
TYPE String
DEFAULT 'http://localhost:8080/odata/productdataapi/v1/';
--- OData client connection
+-- OData client with HTTP(S) metadata URL (production)
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
ODataVersion: OData4,
MetadataUrl: 'http://localhost:8080/odata/productdataapi/v1/$metadata',
@@ -234,6 +289,40 @@ CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
HttpPassword: '1'
);
+-- OData client with local file - relative path (offline development)
+-- Resolved relative to .mpr directory when project is loaded
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: './metadata/productdataapi.xml',
+ Timeout: 300,
+ ServiceUrl: '@ProductClient.ProductDataApiLocation',
+ UseAuthentication: Yes,
+ HttpUsername: 'MxAdmin',
+ HttpPassword: '1'
+);
+
+-- OData client with local file - relative path without ./
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: 'metadata/productdataapi.xml',
+ Timeout: 300,
+ ServiceUrl: '@ProductClient.ProductDataApiLocation',
+ UseAuthentication: Yes,
+ HttpUsername: 'MxAdmin',
+ HttpPassword: '1'
+);
+
+-- OData client with local file - absolute file:// URI
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml',
+ Timeout: 300,
+ ServiceUrl: '@ProductClient.ProductDataApiLocation',
+ UseAuthentication: Yes,
+ HttpUsername: 'MxAdmin',
+ HttpPassword: '1'
+);
+
-- External entities (mapped from published service)
CREATE EXTERNAL ENTITY ProductClient.ProductsEE
FROM ODATA CLIENT ProductClient.ProductDataApiClient
@@ -460,12 +549,39 @@ AUTHENTICATION Basic
## Folder Organization
-Use the `Folder` property to organize OData documents within modules:
+Use the `Folder` property to organize OData documents within modules.
+
+**MetadataUrl accepts three formats:**
+1. **HTTP(S) URL** — fetches from remote service (production)
+2. **file:///absolute/path** — reads from local absolute path
+3. **./path or path/file.xml** — reads from local relative path (resolved against .mpr directory)
```sql
+-- Format 1: HTTP(S) URL
CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
ODataVersion: OData4,
- MetadataUrl: 'http://localhost:8080/odata/productdataapi/v1/$metadata',
+ MetadataUrl: 'https://api.example.com/odata/v4/$metadata',
+ Folder: 'Integration/ProductAPI'
+);
+
+-- Format 2: Absolute file:// URI
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml',
+ Folder: 'Integration/ProductAPI'
+);
+
+-- Format 3a: Relative path with ./
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: './metadata/productdataapi.xml',
+ Folder: 'Integration/ProductAPI'
+);
+
+-- Format 3b: Relative path without ./
+CREATE ODATA CLIENT ProductClient.ProductDataApiClient (
+ ODataVersion: OData4,
+ MetadataUrl: 'metadata/productdataapi.xml',
Folder: 'Integration/ProductAPI'
);
@@ -506,7 +622,11 @@ Before publishing:
Before consuming:
- [ ] Location constant created for environment-specific URLs
-- [ ] OData client points to `$metadata` URL and uses `ServiceUrl: '@Module.Constant'`
+- [ ] OData client `MetadataUrl` points to either:
+ - HTTP(S) URL: `https://api.example.com/$metadata`
+ - Local file (absolute): `file:///path/to/metadata.xml`
+ - Local file (relative): `./metadata/service.xml` (resolved against `.mpr` directory)
+- [ ] OData client uses `ServiceUrl: '@Module.Constant'` for runtime endpoint
- [ ] External entities match the published exposed names and types
- [ ] Module role created and granted on external entities (READ, optionally CREATE/WRITE/DELETE)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ee5a3ef..fbf8291a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@ All notable changes to mxcli will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [Unreleased]
+
+### Added
+
+- **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)
+- **Path normalization** — Relative paths in `MetadataUrl` are automatically converted to absolute `file://` URLs for Studio Pro compatibility
+- **ServiceUrl validation** — `ServiceUrl` parameter must now be a constant reference (e.g., `@Module.ConstantName`) to enforce Mendix best practice
+- **Shared URL utilities** — `internal/pathutil` package with `NormalizeURL()`, `URIToPath()`, and `PathFromURL()` for reuse across components
+
## [0.6.0] - 2026-04-09
### Added
diff --git a/cmd/mxcli/lsp_helpers.go b/cmd/mxcli/lsp_helpers.go
index c3863e04..13751a9e 100644
--- a/cmd/mxcli/lsp_helpers.go
+++ b/cmd/mxcli/lsp_helpers.go
@@ -7,13 +7,13 @@ import (
"context"
"encoding/json"
"fmt"
- "net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
+ "github.com/mendixlabs/mxcli/internal/pathutil"
"go.lsp.dev/protocol"
)
@@ -31,16 +31,9 @@ func (s stdioReadWriteCloser) Write(p []byte) (int, error) { return os.Stdout.Wr
func (s stdioReadWriteCloser) Close() error { return nil }
// uriToPath converts a file:// URI to a filesystem path.
+// Deprecated: use pathutil.URIToPath instead.
func uriToPath(rawURI string) string {
- u, err := url.Parse(rawURI)
- if err != nil {
- return ""
- }
- if u.Scheme == "file" {
- return filepath.FromSlash(u.Path)
- }
- // If no scheme, treat as a raw path
- return rawURI
+ return pathutil.URIToPath(rawURI)
}
// pullConfiguration requests the "mdl" configuration section from the client.
diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md
index c33f5498..9c8ebb15 100644
--- a/docs/01-project/MDL_QUICK_REFERENCE.md
+++ b/docs/01-project/MDL_QUICK_REFERENCE.md
@@ -133,12 +133,40 @@ CREATE CONSTANT MyModule.EnableLogging TYPE Boolean DEFAULT true;
**OData Client Example:**
```sql
+-- HTTP(S) URL (fetches metadata from remote service)
CREATE ODATA CLIENT MyModule.ExternalAPI (
Version: '1.0',
ODataVersion: OData4,
MetadataUrl: 'https://api.example.com/odata/v4/$metadata',
Timeout: 300
);
+
+-- Local file with absolute file:// URI
+CREATE ODATA CLIENT MyModule.LocalService (
+ Version: '1.0',
+ ODataVersion: OData4,
+ MetadataUrl: 'file:///path/to/metadata.xml',
+ Timeout: 300
+);
+
+-- Local file with relative path (normalized to absolute file:// in model)
+CREATE ODATA CLIENT MyModule.LocalService2 (
+ Version: '1.0',
+ ODataVersion: OData4,
+ MetadataUrl: './metadata/service.xml',
+ Timeout: 300,
+ ServiceUrl: '@MyModule.ServiceLocation' -- Must be a constant reference
+);
+```
+
+**Note:** `MetadataUrl` supports three formats:
+- `https://...` or `http://...` — fetches from HTTP(S) endpoint
+- `file:///abs/path` — reads from local absolute path
+- `./path` or `path/file.xml` — reads from local relative path, **normalized to absolute `file://` in the model** for Studio Pro compatibility
+
+**Important:** `ServiceUrl` must always be a constant reference starting with `@` (e.g., `@Module.ConstantName`). Create a constant first:
+```sql
+CREATE CONSTANT MyModule.ServiceLocation TYPE String DEFAULT 'https://api.example.com/odata/v4/';
```
**OData Service Example:**
diff --git a/internal/pathutil/uri.go b/internal/pathutil/uri.go
new file mode 100644
index 00000000..7a6a8fa4
--- /dev/null
+++ b/internal/pathutil/uri.go
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package pathutil
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// URIToPath converts a file:// URI to a filesystem path.
+// If the input is not a valid URI or has a scheme other than "file",
+// returns the input unchanged (treating it as a raw path).
+func URIToPath(rawURI string) string {
+ u, err := url.Parse(rawURI)
+ if err != nil {
+ return ""
+ }
+ if u.Scheme == "file" {
+ return filepath.FromSlash(u.Path)
+ }
+ // If no scheme, treat as a raw path
+ return rawURI
+}
+
+// NormalizeURL converts relative paths to absolute file:// URLs, while preserving HTTP(S) URLs.
+// This is useful for storing URLs in a way that external tools (like Mendix Studio Pro) can
+// reliably distinguish between local files and HTTP endpoints.
+//
+// Supported input formats:
+// - https://... or http://... → returned as-is
+// - file:///abs/path → returned as-is
+// - ./path or path/file.xml → converted to file:///absolute/path
+//
+// If baseDir is provided, relative paths are resolved against it.
+// Otherwise, they're resolved against the current working directory.
+//
+// Returns an error if the path cannot be resolved to an absolute path.
+func NormalizeURL(rawURL string, baseDir string) (string, error) {
+ if rawURL == "" {
+ return "", nil
+ }
+
+ // HTTP(S) URLs are already normalized
+ if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
+ return rawURL, nil
+ }
+
+ // Extract file path from file:// URLs or use raw input
+ filePath := rawURL
+ if strings.HasPrefix(rawURL, "file://") {
+ filePath = URIToPath(rawURL)
+ if filePath == "" {
+ return "", fmt.Errorf("invalid file:// URI: %s", rawURL)
+ }
+ }
+
+ // Convert relative paths to absolute
+ if !filepath.IsAbs(filePath) {
+ if baseDir != "" {
+ filePath = filepath.Join(baseDir, filePath)
+ } else {
+ // No base directory - use cwd
+ cwd, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("failed to resolve relative path: %w", err)
+ }
+ filePath = filepath.Join(cwd, filePath)
+ }
+ }
+
+ // Convert to absolute path (clean up ./ and ../)
+ absPath, err := filepath.Abs(filePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to get absolute path: %w", err)
+ }
+
+ // Return as file:// URL with forward slashes (cross-platform)
+ // RFC 8089 requires three slashes: file:///path or file:///C:/path
+ slashed := filepath.ToSlash(absPath)
+ if !strings.HasPrefix(slashed, "/") {
+ // Windows path like C:/Users/x needs leading slash: file:///C:/Users/x
+ slashed = "/" + slashed
+ }
+ return "file://" + slashed, nil
+}
+
+// PathFromURL extracts a filesystem path from a URL, handling both file:// URLs and HTTP(S) URLs.
+// For file:// URLs, returns the local filesystem path.
+// For HTTP(S) URLs or other schemes, returns an empty string.
+// This is the inverse of converting a path to a file:// URL.
+func PathFromURL(rawURL string) string {
+ if strings.HasPrefix(rawURL, "file://") {
+ return URIToPath(rawURL)
+ }
+ // Not a file:// URL
+ return ""
+}
diff --git a/internal/pathutil/uri_test.go b/internal/pathutil/uri_test.go
new file mode 100644
index 00000000..ab247c7c
--- /dev/null
+++ b/internal/pathutil/uri_test.go
@@ -0,0 +1,222 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package pathutil
+
+import (
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestURIToPath(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "file URI with absolute path",
+ input: "file:///home/user/file.txt",
+ expected: "/home/user/file.txt",
+ },
+ {
+ name: "raw absolute path",
+ input: "/home/user/file.txt",
+ expected: "/home/user/file.txt",
+ },
+ {
+ name: "raw relative path",
+ input: "./metadata/file.xml",
+ expected: "./metadata/file.xml",
+ },
+ {
+ name: "http URL returns unchanged",
+ input: "https://example.com/metadata",
+ expected: "https://example.com/metadata",
+ },
+ {
+ name: "invalid URI returns empty",
+ input: "://invalid",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := URIToPath(tt.input)
+
+ // Skip path separator tests on Windows
+ if runtime.GOOS != "windows" {
+ if result != tt.expected {
+ t.Errorf("URIToPath(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ }
+ })
+ }
+}
+
+func TestURIToPath_Windows(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("Windows-specific test")
+ }
+
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Windows file URI",
+ input: "file:///C:/Users/test/file.txt",
+ expected: "C:\\Users\\test\\file.txt",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := URIToPath(tt.input)
+ if result != tt.expected {
+ t.Errorf("URIToPath(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestNormalizeURL(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ tests := []struct {
+ name string
+ input string
+ baseDir string
+ wantPrefix string
+ wantErr bool
+ }{
+ {
+ name: "HTTP URL unchanged",
+ input: "https://api.example.com/$metadata",
+ baseDir: "",
+ wantPrefix: "https://",
+ wantErr: false,
+ },
+ {
+ name: "HTTPS URL unchanged",
+ input: "http://localhost:8080/odata/$metadata",
+ baseDir: "",
+ wantPrefix: "http://",
+ wantErr: false,
+ },
+ {
+ name: "Absolute file:// URL unchanged",
+ input: "file:///tmp/metadata.xml",
+ baseDir: "",
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Relative path with ./ normalized",
+ input: "./metadata.xml",
+ baseDir: tmpDir,
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Bare relative path normalized",
+ input: "metadata.xml",
+ baseDir: tmpDir,
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Absolute path normalized to file://",
+ input: "/tmp/metadata.xml",
+ baseDir: "",
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Subdirectory relative path",
+ input: "contracts/metadata.xml",
+ baseDir: tmpDir,
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := NormalizeURL(tt.input, tt.baseDir)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if !strings.HasPrefix(result, tt.wantPrefix) {
+ t.Errorf("Result %q does not start with %q", result, tt.wantPrefix)
+ }
+
+ // Verify file:// URLs contain absolute paths
+ if strings.HasPrefix(result, "file://") {
+ path := strings.TrimPrefix(result, "file://")
+ if !filepath.IsAbs(path) {
+ t.Errorf("file:// URL contains relative path: %q", result)
+ }
+ }
+
+ // Verify relative paths are resolved correctly
+ if tt.baseDir != "" && !strings.HasPrefix(tt.input, "http") && !strings.HasPrefix(tt.input, "file://") {
+ path := strings.TrimPrefix(result, "file://")
+ if !strings.Contains(path, filepath.ToSlash(tmpDir)) {
+ t.Errorf("Relative path not resolved against baseDir. Got: %q", result)
+ }
+ }
+ })
+ }
+}
+
+func TestPathFromURL(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "file:// URL extracts path",
+ input: "file:///tmp/metadata.xml",
+ expected: "/tmp/metadata.xml",
+ },
+ {
+ name: "HTTP URL returns empty",
+ input: "https://api.example.com/$metadata",
+ expected: "",
+ },
+ {
+ name: "HTTPS URL returns empty",
+ input: "http://localhost:8080/metadata",
+ expected: "",
+ },
+ {
+ name: "bare path returns empty",
+ input: "/tmp/file.xml",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := PathFromURL(tt.input)
+ if runtime.GOOS != "windows" && result != tt.expected {
+ t.Errorf("PathFromURL(%q) = %q, want %q", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/mdl-examples/odata-local-metadata/README.md b/mdl-examples/odata-local-metadata/README.md
new file mode 100644
index 00000000..43d82251
--- /dev/null
+++ b/mdl-examples/odata-local-metadata/README.md
@@ -0,0 +1,76 @@
+# OData Local Metadata Example
+
+This example demonstrates how to create consumed OData services using local metadata files instead of fetching from HTTP(S) URLs.
+
+## Use Cases
+
+- **Offline development** — work without network access
+- **Testing and CI/CD** — use metadata snapshots for reproducibility
+- **Version-pinned metadata** — lock to a specific metadata version
+- **Pre-production services** — test against metadata files before deployment
+
+## Important Notes
+
+1. **Relative paths are normalized** — Any relative path is automatically converted to an absolute `file://` URL in the Mendix model for Studio Pro compatibility
+2. **ServiceUrl must be a constant** — Always use `@Module.ConstantName` format, not direct URLs
+
+## Supported Formats
+
+### 1. Absolute `file://` URI
+```mdl
+CREATE ODATA CLIENT MyModule.Service (
+ MetadataUrl: 'file:///absolute/path/to/metadata.xml'
+);
+```
+
+### 2. Relative path (with or without `./`)
+```mdl
+-- Resolved relative to the .mpr file's directory, then normalized to absolute file://
+-- Example: './metadata/service.xml' → 'file:///absolute/path/to/project/metadata/service.xml'
+CREATE ODATA CLIENT MyModule.Service (
+ MetadataUrl: './metadata/service.xml',
+ ServiceUrl: '@MyModule.ServiceLocation'
+);
+
+CREATE ODATA CLIENT MyModule.Service2 (
+ MetadataUrl: 'metadata/service.xml',
+ ServiceUrl: '@MyModule.ServiceLocation'
+);
+```
+
+### 3. HTTP(S) URL (existing behavior)
+```mdl
+CREATE ODATA CLIENT MyModule.Service (
+ MetadataUrl: 'https://api.example.com/$metadata'
+);
+```
+
+## Path Resolution
+
+| Scenario | Base Directory |
+|----------|----------------|
+| Project loaded (`-p` flag or REPL with project) | Relative to `.mpr` file's directory |
+| No project loaded (`mxcli check` without `-p`) | Relative to current working directory |
+
+## Running the Example
+
+```bash
+# From the project root
+./bin/mxcli exec mdl-examples/odata-local-metadata/example.mdl -p path/to/app.mpr
+
+# Or in REPL
+./bin/mxcli -p path/to/app.mpr
+> .read mdl-examples/odata-local-metadata/example.mdl
+```
+
+## Hash Calculation
+
+Local files are hashed identically to HTTP-fetched metadata (SHA-256). Editing the local XML file invalidates the cached metadata, just like a remote service change would.
+
+## Benefits
+
+- ✅ No network required
+- ✅ Reproducible builds
+- ✅ Version control friendly (commit metadata alongside code)
+- ✅ Firewall-friendly
+- ✅ Fast iteration during development
diff --git a/mdl-examples/odata-local-metadata/example.mdl b/mdl-examples/odata-local-metadata/example.mdl
new file mode 100644
index 00000000..dde503c4
--- /dev/null
+++ b/mdl-examples/odata-local-metadata/example.mdl
@@ -0,0 +1,43 @@
+-- Example: Using local OData metadata files
+--
+-- This demonstrates three ways to specify local metadata files for OData clients:
+-- 1. Absolute file:// URI (stored as-is)
+-- 2. Relative path with ./ (normalized to absolute file://)
+-- 3. Relative path without ./ (normalized to absolute file://)
+--
+-- IMPORTANT: ServiceUrl must be a constant reference (prefixed with @)
+
+-- First, create constants for service locations (required for ServiceUrl)
+CREATE CONSTANT TestModule.NorthwindLocation
+ TYPE String
+ DEFAULT 'https://services.odata.org/V4/Northwind/Northwind.svc/';
+
+-- Method 1: Absolute file:// URI (stored as-is in model)
+-- Useful when the metadata file is in a fixed location
+CREATE ODATA CLIENT TestModule.NorthwindAbsolute (
+ MetadataUrl: 'file:///tmp/northwind-metadata.xml',
+ ServiceUrl: '@TestModule.NorthwindLocation'
+);
+
+-- Method 2: Relative path with ./ (normalized to absolute file:// in model)
+-- Best for metadata files stored alongside the project
+-- Example: './metadata/file.xml' → 'file:///absolute/path/to/project/metadata/file.xml'
+CREATE ODATA CLIENT TestModule.NorthwindRelative (
+ MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml',
+ ServiceUrl: '@TestModule.NorthwindLocation'
+);
+
+-- Method 3: Relative path without ./ (normalized to absolute file:// in model)
+CREATE ODATA CLIENT TestModule.NorthwindSimple (
+ MetadataUrl: 'mdl-examples/odata-local-metadata/sample-metadata.xml',
+ ServiceUrl: '@TestModule.NorthwindLocation'
+);
+
+-- HTTP(S) URLs still work as before (stored as-is in model)
+CREATE ODATA CLIENT TestModule.NorthwindRemote (
+ MetadataUrl: 'https://services.odata.org/V4/Northwind/Northwind.svc/$metadata',
+ ServiceUrl: '@TestModule.NorthwindLocation'
+);
+
+-- Show the created clients (note: relative paths appear as file:// URLs)
+SHOW ODATA CLIENTS IN TestModule;
diff --git a/mdl-examples/odata-local-metadata/sample-metadata.xml b/mdl-examples/odata-local-metadata/sample-metadata.xml
new file mode 100644
index 00000000..3f622828
--- /dev/null
+++ b/mdl-examples/odata-local-metadata/sample-metadata.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go
index 673d0908..3b2ad2cc 100644
--- a/mdl/executor/cmd_odata.go
+++ b/mdl/executor/cmd_odata.go
@@ -7,10 +7,13 @@ import (
"fmt"
"io"
"net/http"
+ "os"
+ "path/filepath"
"sort"
"strings"
"time"
+ "github.com/mendixlabs/mxcli/internal/pathutil"
"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
@@ -988,6 +991,16 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error {
ClientCertificate: stmt.ClientCertificate,
}
if stmt.ServiceUrl != "" {
+ // ServiceUrl must be a constant reference (e.g., @Module.ConstantName)
+ if !strings.HasPrefix(stmt.ServiceUrl, "@") {
+ return fmt.Errorf(`ServiceUrl must now be a constant reference (e.g., '@Module.ApiLocation').
+Previously literal URLs were allowed; this enforces the Mendix best practice of externalizing configuration.
+Create a constant first:
+ CREATE CONSTANT Module.ApiLocation TYPE String DEFAULT 'https://api.example.com/';
+Then reference it:
+ ServiceUrl: '@Module.ApiLocation'
+Got: %s`, stmt.ServiceUrl)
+ }
cfg.OverrideLocation = true
cfg.CustomLocation = stmt.ServiceUrl
}
@@ -1001,8 +1014,21 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error {
}
// Fetch and cache $metadata from the service URL
+ // Normalize local file paths to absolute file:// URLs for Studio Pro compatibility
if newSvc.MetadataUrl != "" {
- metadata, hash, err := fetchODataMetadata(newSvc.MetadataUrl)
+ mprDir := ""
+ if e.mprPath != "" {
+ mprDir = filepath.Dir(e.mprPath)
+ }
+
+ // Normalize MetadataUrl: convert relative paths to absolute file:// URLs
+ normalizedUrl, err := pathutil.NormalizeURL(newSvc.MetadataUrl, mprDir)
+ if err != nil {
+ return fmt.Errorf("failed to normalize MetadataUrl: %w", err)
+ }
+ newSvc.MetadataUrl = normalizedUrl
+
+ metadata, hash, err := fetchODataMetadata(normalizedUrl)
if err != nil {
fmt.Fprintf(e.output, "Warning: could not fetch $metadata: %v\n", err)
} else if metadata != "" {
@@ -1404,29 +1430,53 @@ func astEntityDefToModel(def *ast.PublishedEntityDef) (*model.PublishedEntityTyp
return entityType, entitySet
}
-// fetchODataMetadata downloads the $metadata document from the service URL.
+// fetchODataMetadata downloads or reads the $metadata document.
+// Supports:
+// - https://... or http://... (HTTP fetch)
+// - file:///abs/path (local absolute path from normalized URL)
+//
// Returns the metadata XML and its SHA-256 hash, or empty strings if the fetch fails.
+// Note: metadataUrl is expected to be already normalized by NormalizeURL() in createODataClient,
+// so all relative paths have been converted to absolute file:// URLs.
func fetchODataMetadata(metadataUrl string) (metadata string, hash string, err error) {
if metadataUrl == "" {
return "", "", nil
}
- client := &http.Client{Timeout: 30 * time.Second}
- resp, err := client.Get(metadataUrl)
- if err != nil {
- return "", "", fmt.Errorf("failed to fetch $metadata from %s: %w", metadataUrl, err)
- }
- defer resp.Body.Close()
+ var body []byte
- if resp.StatusCode != http.StatusOK {
- return "", "", fmt.Errorf("$metadata fetch returned HTTP %d from %s", resp.StatusCode, metadataUrl)
- }
+ // At this point, metadataUrl is already normalized by NormalizeURL() in createODataClient:
+ // - Relative paths have been converted to absolute file:// URLs
+ // - HTTP(S) URLs are unchanged
+ // So we only need to distinguish file:// vs HTTP(S)
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return "", "", fmt.Errorf("failed to read $metadata response: %w", err)
+ filePath := pathutil.PathFromURL(metadataUrl)
+ if filePath != "" {
+ // Local file - read directly (path is already absolute)
+ body, err = os.ReadFile(filePath)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to read local metadata file %s: %w", filePath, err)
+ }
+ } else {
+ // HTTP(S) fetch
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Get(metadataUrl)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to fetch $metadata from %s: %w", metadataUrl, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("$metadata fetch returned HTTP %d from %s", resp.StatusCode, metadataUrl)
+ }
+
+ body, err = io.ReadAll(resp.Body)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to read $metadata response: %w", err)
+ }
}
+ // Hash calculation (same for both HTTP and local file)
metadata = string(body)
h := sha256.Sum256(body)
hash = fmt.Sprintf("%x", h)
diff --git a/mdl/executor/cmd_odata_test.go b/mdl/executor/cmd_odata_test.go
new file mode 100644
index 00000000..9df6b8f0
--- /dev/null
+++ b/mdl/executor/cmd_odata_test.go
@@ -0,0 +1,195 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package executor
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/mendixlabs/mxcli/internal/pathutil"
+)
+
+func TestFetchODataMetadata_LocalFile(t *testing.T) {
+ // Create a temporary metadata file
+ tmpDir := t.TempDir()
+ metadataContent := ``
+ metadataPath := filepath.Join(tmpDir, "metadata.xml")
+ if err := os.WriteFile(metadataPath, []byte(metadataContent), 0644); err != nil {
+ t.Fatalf("Failed to create test metadata file: %v", err)
+ }
+
+ // Convert to proper file:// URL (RFC 8089 compliant)
+ fileURL, err := pathutil.NormalizeURL(metadataPath, tmpDir)
+ if err != nil {
+ t.Fatalf("Failed to normalize path: %v", err)
+ }
+
+ tests := []struct {
+ name string
+ url string
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "RFC 8089 file:// URL",
+ url: fileURL,
+ wantErr: false,
+ },
+ {
+ name: "nonexistent file",
+ url: "file:///nonexistent/metadata.xml",
+ wantErr: true,
+ errContains: "failed to read local metadata file",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ metadata, hash, err := fetchODataMetadata(tt.url)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
+ t.Errorf("Error %q does not contain %q", err.Error(), tt.errContains)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if metadata != metadataContent {
+ t.Errorf("Metadata content mismatch.\nGot: %q\nWant: %q", metadata, metadataContent)
+ }
+
+ if hash == "" {
+ t.Errorf("Expected non-empty hash")
+ }
+
+ // Hash should be consistent
+ _, hash2, _ := fetchODataMetadata(tt.url)
+ if hash != hash2 {
+ t.Errorf("Hash inconsistent between calls: %q vs %q", hash, hash2)
+ }
+ })
+ }
+}
+
+func TestNormalizeMetadataUrl(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ tests := []struct {
+ name string
+ input string
+ baseDir string
+ wantPrefix string
+ wantErr bool
+ }{
+ {
+ name: "HTTP URL unchanged",
+ input: "https://api.example.com/$metadata",
+ baseDir: "",
+ wantPrefix: "https://",
+ wantErr: false,
+ },
+ {
+ name: "HTTPS URL unchanged",
+ input: "http://localhost:8080/odata/$metadata",
+ baseDir: "",
+ wantPrefix: "http://",
+ wantErr: false,
+ },
+ {
+ name: "Absolute file:// unchanged",
+ input: "file:///tmp/metadata.xml",
+ baseDir: "",
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Relative path normalized to file://",
+ input: "./metadata.xml",
+ baseDir: tmpDir,
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Bare relative path normalized to file://",
+ input: "metadata.xml",
+ baseDir: tmpDir,
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ {
+ name: "Absolute path normalized to file://",
+ input: "/tmp/metadata.xml",
+ baseDir: "",
+ wantPrefix: "file://",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := pathutil.NormalizeURL(tt.input, tt.baseDir)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ return
+ }
+
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+
+ if !strings.HasPrefix(result, tt.wantPrefix) {
+ t.Errorf("Result %q does not start with %q", result, tt.wantPrefix)
+ }
+
+ // Verify file:// URLs are absolute
+ if strings.HasPrefix(result, "file://") {
+ path := strings.TrimPrefix(result, "file://")
+ if !filepath.IsAbs(path) {
+ t.Errorf("file:// URL contains relative path: %q", result)
+ }
+ }
+ })
+ }
+}
+
+func TestFetchODataMetadata_LocalFileAbsolute(t *testing.T) {
+ // Create metadata file with absolute path
+ tmpDir := t.TempDir()
+
+ metadataContent := ``
+ filePath := filepath.Join(tmpDir, "local.xml")
+ if err := os.WriteFile(filePath, []byte(metadataContent), 0644); err != nil {
+ t.Fatalf("Failed to create test file: %v", err)
+ }
+
+ // Convert to file:// URL (simulates what NormalizeURL does)
+ fileURL := "file://" + filepath.ToSlash(filePath)
+ if !strings.HasPrefix(filePath, "/") {
+ // Windows: add leading slash for RFC 8089 compliance
+ fileURL = "file:///" + filepath.ToSlash(filePath)
+ }
+
+ metadata, hash, err := fetchODataMetadata(fileURL)
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ }
+ if metadata != metadataContent {
+ t.Errorf("Metadata content mismatch")
+ }
+ if hash == "" {
+ t.Errorf("Expected non-empty hash")
+ }
+}