From 064b511f9aa1cea0f01a9166ae03d0b2d13d2d49 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Wed, 15 Apr 2026 17:41:30 +0200 Subject: [PATCH 1/6] feat: support file:// URLs and local paths for OData metadata Extends CREATE ODATA CLIENT MetadataUrl to accept local file paths in addition to HTTP(S) URLs, enabling offline development and reproducible testing with metadata snapshots. Supported formats: - https://... or http://... (existing HTTP fetch) - file:///abs/path (local absolute path) - ./path or path/file.xml (local relative path) Path resolution: - With project loaded: relative to .mpr directory - Without project: relative to cwd Implementation: - Created internal/pathutil package with URIToPath() helper - Refactored cmd/mxcli/lsp_helpers.go to use shared utility - Updated fetchODataMetadata() to detect and handle local files - Added comprehensive test coverage Closes #206 Co-Authored-By: Claude Sonnet 4.5 --- cmd/mxcli/lsp_helpers.go | 13 +- docs/01-project/MDL_QUICK_REFERENCE.md | 22 ++++ internal/pathutil/uri.go | 23 ++++ internal/pathutil/uri_test.go | 82 ++++++++++++ mdl-examples/odata-local-metadata/README.md | 68 ++++++++++ mdl-examples/odata-local-metadata/example.mdl | 31 +++++ .../odata-local-metadata/sample-metadata.xml | 34 +++++ mdl/executor/cmd_odata.go | 81 +++++++++--- mdl/executor/cmd_odata_test.go | 119 ++++++++++++++++++ 9 files changed, 448 insertions(+), 25 deletions(-) create mode 100644 internal/pathutil/uri.go create mode 100644 internal/pathutil/uri_test.go create mode 100644 mdl-examples/odata-local-metadata/README.md create mode 100644 mdl-examples/odata-local-metadata/example.mdl create mode 100644 mdl-examples/odata-local-metadata/sample-metadata.xml create mode 100644 mdl/executor/cmd_odata_test.go 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..52e4a1de 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -133,14 +133,36 @@ 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 (resolved against .mpr directory) +CREATE ODATA CLIENT MyModule.LocalService2 ( + Version: '1.0', + ODataVersion: OData4, + MetadataUrl: './metadata/service.xml', + Timeout: 300 +); ``` +**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 (resolved against `.mpr` directory when project is loaded, or `cwd` otherwise) + **OData Service Example:** ```sql CREATE ODATA SERVICE MyModule.CustomerAPI ( diff --git a/internal/pathutil/uri.go b/internal/pathutil/uri.go new file mode 100644 index 00000000..1e8ac205 --- /dev/null +++ b/internal/pathutil/uri.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pathutil + +import ( + "net/url" + "path/filepath" +) + +// 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 +} diff --git a/internal/pathutil/uri_test.go b/internal/pathutil/uri_test.go new file mode 100644 index 00000000..04380721 --- /dev/null +++ b/internal/pathutil/uri_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +package pathutil + +import ( + "runtime" + "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) + } + }) + } +} diff --git a/mdl-examples/odata-local-metadata/README.md b/mdl-examples/odata-local-metadata/README.md new file mode 100644 index 00000000..127b02af --- /dev/null +++ b/mdl-examples/odata-local-metadata/README.md @@ -0,0 +1,68 @@ +# 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 + +## 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 when project is loaded +CREATE ODATA CLIENT MyModule.Service ( + MetadataUrl: './metadata/service.xml' +); + +CREATE ODATA CLIENT MyModule.Service2 ( + MetadataUrl: 'metadata/service.xml' +); +``` + +### 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..9d10fc7b --- /dev/null +++ b/mdl-examples/odata-local-metadata/example.mdl @@ -0,0 +1,31 @@ +-- Example: Using local OData metadata files +-- +-- This demonstrates three ways to specify local metadata files for OData clients: +-- 1. Absolute file:// URI +-- 2. Relative path with ./ +-- 3. Relative path without ./ + +-- Method 1: Absolute file:// URI (works anywhere) +-- Useful when the metadata file is in a fixed location +CREATE ODATA CLIENT TestModule.NorthwindAbsolute ( + MetadataUrl: 'file:///tmp/northwind-metadata.xml' +); + +-- Method 2: Relative path with ./ (resolved against .mpr directory) +-- Best for metadata files stored alongside the project +CREATE ODATA CLIENT TestModule.NorthwindRelative ( + MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml' +); + +-- Method 3: Relative path without ./ (also resolved against .mpr directory) +CREATE ODATA CLIENT TestModule.NorthwindSimple ( + MetadataUrl: 'mdl-examples/odata-local-metadata/sample-metadata.xml' +); + +-- HTTP(S) URLs still work as before +CREATE ODATA CLIENT TestModule.NorthwindRemote ( + MetadataUrl: 'https://services.odata.org/V4/Northwind/Northwind.svc/$metadata' +); + +-- Show the created clients +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..e6ac9b9d 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" @@ -1002,7 +1005,11 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error { // Fetch and cache $metadata from the service URL if newSvc.MetadataUrl != "" { - metadata, hash, err := fetchODataMetadata(newSvc.MetadataUrl) + mprDir := "" + if e.mprPath != "" { + mprDir = filepath.Dir(e.mprPath) + } + metadata, hash, err := fetchODataMetadata(newSvc.MetadataUrl, mprDir) if err != nil { fmt.Fprintf(e.output, "Warning: could not fetch $metadata: %v\n", err) } else if metadata != "" { @@ -1404,29 +1411,73 @@ 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) +// - ./path or path/file.xml (local relative path, resolved against mprDir) +// // Returns the metadata XML and its SHA-256 hash, or empty strings if the fetch fails. -func fetchODataMetadata(metadataUrl string) (metadata string, hash string, err error) { +// If mprDir is empty and a relative path is given, resolves against cwd. +func fetchODataMetadata(metadataUrl string, mprDir 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) - } + // Detect if this is a local file path (file:// or not http/https) + isHTTP := strings.HasPrefix(metadataUrl, "http://") || strings.HasPrefix(metadataUrl, "https://") - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", fmt.Errorf("failed to read $metadata response: %w", err) + if !isHTTP { + // Local file path - extract and resolve + filePath := metadataUrl + if strings.HasPrefix(metadataUrl, "file://") { + filePath = pathutil.URIToPath(metadataUrl) + if filePath == "" { + return "", "", fmt.Errorf("invalid file:// URI: %s", metadataUrl) + } + } + + // Resolve relative paths + if !filepath.IsAbs(filePath) { + if mprDir != "" { + filePath = filepath.Join(mprDir, filePath) + } else { + // No project loaded - use cwd + cwd, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("failed to resolve relative path: %w", err) + } + filePath = filepath.Join(cwd, filePath) + } + } + + // Read local file + 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..1a9c8e8f --- /dev/null +++ b/mdl/executor/cmd_odata_test.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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) + } + + tests := []struct { + name string + url string + mprDir string + wantErr bool + errContains string + }{ + { + name: "absolute file:// URL", + url: "file://" + metadataPath, + mprDir: "", + wantErr: false, + }, + { + name: "absolute path without file://", + url: metadataPath, + mprDir: "", + wantErr: false, + }, + { + name: "relative path with mprDir", + url: "metadata.xml", + mprDir: tmpDir, + wantErr: false, + }, + { + name: "relative path with ./ prefix", + url: "./metadata.xml", + mprDir: tmpDir, + wantErr: false, + }, + { + name: "nonexistent file", + url: "file:///nonexistent/metadata.xml", + mprDir: "", + 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, tt.mprDir) + + 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, tt.mprDir) + if hash != hash2 { + t.Errorf("Hash inconsistent between calls: %q vs %q", hash, hash2) + } + }) + } +} + +func TestFetchODataMetadata_RelativePathWithoutProject(t *testing.T) { + // Create metadata file in current directory + tmpDir := t.TempDir() + oldCwd, _ := os.Getwd() + defer os.Chdir(oldCwd) + os.Chdir(tmpDir) + + metadataContent := `` + if err := os.WriteFile("local.xml", []byte(metadataContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test with empty mprDir (should resolve against cwd) + metadata, hash, err := fetchODataMetadata("local.xml", "") + 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") + } +} From 4ad2cfeb51ef579c81fcd48dad74fe39a434d326 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Wed, 15 Apr 2026 23:58:46 +0200 Subject: [PATCH 2/6] docs: update skills with comprehensive local metadata examples Updated OData-related skill files to comprehensively document all three MetadataUrl formats with complete examples. Changes to odata-data-sharing.md: - Added new "MetadataUrl Formats" section with table and use cases - Updated Step 4 examples to show all formats: * HTTP(S) URL (production) * Relative path with ./ (offline development) * Relative path without ./ * Absolute file:// URI - Updated "Folder Organization" section with all formats - Updated checklist with detailed MetadataUrl options Changes to browse-integrations.md: - Added note about local file support with all three formats - Documented path resolution behavior - Listed use cases for local metadata files Co-Authored-By: Claude Sonnet 4.5 --- .claude/skills/mendix/browse-integrations.md | 9 +- .claude/skills/mendix/odata-data-sharing.md | 95 +++++++++++++++++++- 2 files changed, 99 insertions(+), 5 deletions(-) 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..69c48e23 100644 --- a/.claude/skills/mendix/odata-data-sharing.md +++ b/.claude/skills/mendix/odata-data-sharing.md @@ -10,6 +10,28 @@ 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 | Use Case | +|--------|---------|----------| +| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Production, fetches from live service | +| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Local metadata at fixed path | +| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | Metadata alongside project (offline, testing, CI/CD) | + +**Path Resolution:** +- 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 ## Architecture Overview @@ -223,7 +245,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 +256,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 +516,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 +589,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) From 2660693fc8f59ac831ea4538b287d6bdb4280002 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 11:15:05 +0200 Subject: [PATCH 3/6] feat: normalize relative paths and enforce ServiceUrl as constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/mendix/odata-data-sharing.md | 47 ++++++++-- docs/01-project/MDL_QUICK_REFERENCE.md | 12 ++- mdl-examples/odata-local-metadata/README.md | 14 ++- mdl-examples/odata-local-metadata/example.mdl | 36 +++++--- mdl/executor/cmd_odata.go | 68 ++++++++++++++- mdl/executor/cmd_odata_test.go | 85 +++++++++++++++++++ 6 files changed, 236 insertions(+), 26 deletions(-) diff --git a/.claude/skills/mendix/odata-data-sharing.md b/.claude/skills/mendix/odata-data-sharing.md index 69c48e23..bd471817 100644 --- a/.claude/skills/mendix/odata-data-sharing.md +++ b/.claude/skills/mendix/odata-data-sharing.md @@ -16,13 +16,18 @@ This skill covers how to use OData services to share data between Mendix applica `CREATE ODATA CLIENT` supports three formats for the `MetadataUrl` parameter: -| Format | Example | Use Case | -|--------|---------|----------| -| **HTTP(S) URL** | `https://api.example.com/odata/v4/$metadata` | Production, fetches from live service | -| **Absolute file:// URI** | `file:///Users/team/contracts/service.xml` | Local metadata at fixed path | -| **Relative path** | `./metadata/service.xml` or `metadata/service.xml` | Metadata alongside project (offline, testing, CI/CD) | - -**Path Resolution:** +| 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 @@ -33,6 +38,34 @@ This skill covers how to use OData services to share data between Mendix applica - **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 OData data sharing follows a **producer/consumer** pattern with three layers: diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index 52e4a1de..9c8ebb15 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -149,19 +149,25 @@ CREATE ODATA CLIENT MyModule.LocalService ( Timeout: 300 ); --- Local file with relative path (resolved against .mpr directory) +-- 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 + 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 (resolved against `.mpr` directory when project is loaded, or `cwd` otherwise) +- `./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:** ```sql diff --git a/mdl-examples/odata-local-metadata/README.md b/mdl-examples/odata-local-metadata/README.md index 127b02af..43d82251 100644 --- a/mdl-examples/odata-local-metadata/README.md +++ b/mdl-examples/odata-local-metadata/README.md @@ -9,6 +9,11 @@ This example demonstrates how to create consumed OData services using local meta - **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 @@ -20,13 +25,16 @@ CREATE ODATA CLIENT MyModule.Service ( ### 2. Relative path (with or without `./`) ```mdl --- Resolved relative to the .mpr file's directory when project is loaded +-- 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' + MetadataUrl: './metadata/service.xml', + ServiceUrl: '@MyModule.ServiceLocation' ); CREATE ODATA CLIENT MyModule.Service2 ( - MetadataUrl: 'metadata/service.xml' + MetadataUrl: 'metadata/service.xml', + ServiceUrl: '@MyModule.ServiceLocation' ); ``` diff --git a/mdl-examples/odata-local-metadata/example.mdl b/mdl-examples/odata-local-metadata/example.mdl index 9d10fc7b..dde503c4 100644 --- a/mdl-examples/odata-local-metadata/example.mdl +++ b/mdl-examples/odata-local-metadata/example.mdl @@ -1,31 +1,43 @@ -- Example: Using local OData metadata files -- -- This demonstrates three ways to specify local metadata files for OData clients: --- 1. Absolute file:// URI --- 2. Relative path with ./ --- 3. Relative path without ./ +-- 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 (works anywhere) +-- 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' + MetadataUrl: 'file:///tmp/northwind-metadata.xml', + ServiceUrl: '@TestModule.NorthwindLocation' ); --- Method 2: Relative path with ./ (resolved against .mpr directory) +-- 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' + MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml', + ServiceUrl: '@TestModule.NorthwindLocation' ); --- Method 3: Relative path without ./ (also resolved against .mpr directory) +-- 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' + MetadataUrl: 'mdl-examples/odata-local-metadata/sample-metadata.xml', + ServiceUrl: '@TestModule.NorthwindLocation' ); --- HTTP(S) URLs still work as before +-- 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' + MetadataUrl: 'https://services.odata.org/V4/Northwind/Northwind.svc/$metadata', + ServiceUrl: '@TestModule.NorthwindLocation' ); --- Show the created clients +-- Show the created clients (note: relative paths appear as file:// URLs) SHOW ODATA CLIENTS IN TestModule; diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index e6ac9b9d..5166633f 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -991,6 +991,10 @@ 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 be a constant reference starting with '@' (e.g., '@Module.LocationConstant'), got: %s", stmt.ServiceUrl) + } cfg.OverrideLocation = true cfg.CustomLocation = stmt.ServiceUrl } @@ -1004,12 +1008,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 != "" { mprDir := "" if e.mprPath != "" { mprDir = filepath.Dir(e.mprPath) } - metadata, hash, err := fetchODataMetadata(newSvc.MetadataUrl, mprDir) + + // Normalize MetadataUrl: convert relative paths to absolute file:// URLs + normalizedUrl, err := normalizeMetadataUrl(newSvc.MetadataUrl, mprDir) + if err != nil { + return fmt.Errorf("failed to normalize MetadataUrl: %w", err) + } + newSvc.MetadataUrl = normalizedUrl + + metadata, hash, err := fetchODataMetadata(normalizedUrl, mprDir) if err != nil { fmt.Fprintf(e.output, "Warning: could not fetch $metadata: %v\n", err) } else if metadata != "" { @@ -1411,6 +1424,59 @@ func astEntityDefToModel(def *ast.PublishedEntityDef) (*model.PublishedEntityTyp return entityType, entitySet } +// normalizeMetadataUrl converts relative paths to absolute file:// URLs. +// This ensures Studio Pro can properly detect local file vs HTTP metadata sources. +// +// 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 mprDir is provided, relative paths are resolved against it. +// Otherwise, they're resolved against the current working directory. +func normalizeMetadataUrl(metadataUrl string, mprDir string) (string, error) { + if metadataUrl == "" { + return "", nil + } + + // HTTP(S) URLs are already normalized + if strings.HasPrefix(metadataUrl, "http://") || strings.HasPrefix(metadataUrl, "https://") { + return metadataUrl, nil + } + + // Extract file path + filePath := metadataUrl + if strings.HasPrefix(metadataUrl, "file://") { + filePath = pathutil.URIToPath(metadataUrl) + if filePath == "" { + return "", fmt.Errorf("invalid file:// URI: %s", metadataUrl) + } + } + + // Convert relative paths to absolute + if !filepath.IsAbs(filePath) { + if mprDir != "" { + filePath = filepath.Join(mprDir, filePath) + } else { + // No project loaded - 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 + return "file://" + filepath.ToSlash(absPath), nil +} + // fetchODataMetadata downloads or reads the $metadata document. // Supports: // - https://... or http://... (HTTP fetch) diff --git a/mdl/executor/cmd_odata_test.go b/mdl/executor/cmd_odata_test.go index 1a9c8e8f..d90f7dc0 100644 --- a/mdl/executor/cmd_odata_test.go +++ b/mdl/executor/cmd_odata_test.go @@ -93,6 +93,91 @@ func TestFetchODataMetadata_LocalFile(t *testing.T) { } } +func TestNormalizeMetadataUrl(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + name string + input string + mprDir string + wantPrefix string + wantErr bool + }{ + { + name: "HTTP URL unchanged", + input: "https://api.example.com/$metadata", + mprDir: "", + wantPrefix: "https://", + wantErr: false, + }, + { + name: "HTTPS URL unchanged", + input: "http://localhost:8080/odata/$metadata", + mprDir: "", + wantPrefix: "http://", + wantErr: false, + }, + { + name: "Absolute file:// unchanged", + input: "file:///tmp/metadata.xml", + mprDir: "", + wantPrefix: "file://", + wantErr: false, + }, + { + name: "Relative path normalized to file://", + input: "./metadata.xml", + mprDir: tmpDir, + wantPrefix: "file://", + wantErr: false, + }, + { + name: "Bare relative path normalized to file://", + input: "metadata.xml", + mprDir: tmpDir, + wantPrefix: "file://", + wantErr: false, + }, + { + name: "Absolute path normalized to file://", + input: "/tmp/metadata.xml", + mprDir: "", + wantPrefix: "file://", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := normalizeMetadataUrl(tt.input, tt.mprDir) + + 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_RelativePathWithoutProject(t *testing.T) { // Create metadata file in current directory tmpDir := t.TempDir() From cc64aa43fb60dae605c532af15ebdc0585fd852e Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 11:20:55 +0200 Subject: [PATCH 4/6] 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 --- internal/pathutil/uri.go | 71 +++++++++++++++++ internal/pathutil/uri_test.go | 140 +++++++++++++++++++++++++++++++++ mdl/executor/cmd_odata.go | 55 +------------ mdl/executor/cmd_odata_test.go | 18 +++-- 4 files changed, 222 insertions(+), 62 deletions(-) diff --git a/internal/pathutil/uri.go b/internal/pathutil/uri.go index 1e8ac205..77200792 100644 --- a/internal/pathutil/uri.go +++ b/internal/pathutil/uri.go @@ -3,8 +3,11 @@ package pathutil import ( + "fmt" "net/url" + "os" "path/filepath" + "strings" ) // URIToPath converts a file:// URI to a filesystem path. @@ -21,3 +24,71 @@ func URIToPath(rawURI string) string { // 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) + return "file://" + filepath.ToSlash(absPath), 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 index 04380721..ab247c7c 100644 --- a/internal/pathutil/uri_test.go +++ b/internal/pathutil/uri_test.go @@ -3,7 +3,9 @@ package pathutil import ( + "path/filepath" "runtime" + "strings" "testing" ) @@ -80,3 +82,141 @@ func TestURIToPath_Windows(t *testing.T) { }) } } + +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/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 5166633f..1108d80c 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -1016,7 +1016,7 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error { } // Normalize MetadataUrl: convert relative paths to absolute file:// URLs - normalizedUrl, err := normalizeMetadataUrl(newSvc.MetadataUrl, mprDir) + normalizedUrl, err := pathutil.NormalizeURL(newSvc.MetadataUrl, mprDir) if err != nil { return fmt.Errorf("failed to normalize MetadataUrl: %w", err) } @@ -1424,59 +1424,6 @@ func astEntityDefToModel(def *ast.PublishedEntityDef) (*model.PublishedEntityTyp return entityType, entitySet } -// normalizeMetadataUrl converts relative paths to absolute file:// URLs. -// This ensures Studio Pro can properly detect local file vs HTTP metadata sources. -// -// 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 mprDir is provided, relative paths are resolved against it. -// Otherwise, they're resolved against the current working directory. -func normalizeMetadataUrl(metadataUrl string, mprDir string) (string, error) { - if metadataUrl == "" { - return "", nil - } - - // HTTP(S) URLs are already normalized - if strings.HasPrefix(metadataUrl, "http://") || strings.HasPrefix(metadataUrl, "https://") { - return metadataUrl, nil - } - - // Extract file path - filePath := metadataUrl - if strings.HasPrefix(metadataUrl, "file://") { - filePath = pathutil.URIToPath(metadataUrl) - if filePath == "" { - return "", fmt.Errorf("invalid file:// URI: %s", metadataUrl) - } - } - - // Convert relative paths to absolute - if !filepath.IsAbs(filePath) { - if mprDir != "" { - filePath = filepath.Join(mprDir, filePath) - } else { - // No project loaded - 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 - return "file://" + filepath.ToSlash(absPath), nil -} - // fetchODataMetadata downloads or reads the $metadata document. // Supports: // - https://... or http://... (HTTP fetch) diff --git a/mdl/executor/cmd_odata_test.go b/mdl/executor/cmd_odata_test.go index d90f7dc0..048c73d5 100644 --- a/mdl/executor/cmd_odata_test.go +++ b/mdl/executor/cmd_odata_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/mendixlabs/mxcli/internal/pathutil" ) func TestFetchODataMetadata_LocalFile(t *testing.T) { @@ -99,49 +101,49 @@ func TestNormalizeMetadataUrl(t *testing.T) { tests := []struct { name string input string - mprDir string + baseDir string wantPrefix string wantErr bool }{ { name: "HTTP URL unchanged", input: "https://api.example.com/$metadata", - mprDir: "", + baseDir: "", wantPrefix: "https://", wantErr: false, }, { name: "HTTPS URL unchanged", input: "http://localhost:8080/odata/$metadata", - mprDir: "", + baseDir: "", wantPrefix: "http://", wantErr: false, }, { name: "Absolute file:// unchanged", input: "file:///tmp/metadata.xml", - mprDir: "", + baseDir: "", wantPrefix: "file://", wantErr: false, }, { name: "Relative path normalized to file://", input: "./metadata.xml", - mprDir: tmpDir, + baseDir: tmpDir, wantPrefix: "file://", wantErr: false, }, { name: "Bare relative path normalized to file://", input: "metadata.xml", - mprDir: tmpDir, + baseDir: tmpDir, wantPrefix: "file://", wantErr: false, }, { name: "Absolute path normalized to file://", input: "/tmp/metadata.xml", - mprDir: "", + baseDir: "", wantPrefix: "file://", wantErr: false, }, @@ -149,7 +151,7 @@ func TestNormalizeMetadataUrl(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := normalizeMetadataUrl(tt.input, tt.mprDir) + result, err := pathutil.NormalizeURL(tt.input, tt.baseDir) if tt.wantErr { if err == nil { From eb23c9bfd7161ecb051eae04bb5f3899eb3fd186 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 11:37:07 +0200 Subject: [PATCH 5/6] docs: update CHANGELOG for local metadata support --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From a2057f8601d607cee9d90903422655c616c01fa6 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 15:10:02 +0200 Subject: [PATCH 6/6] fix: address review feedback from PR #210 Fixes four issues identified by @ako in code review: 1. Windows file:// URL RFC 8089 compliance - Add leading slash for Windows paths: file:///C:/path - Previously: file://C:/path (incorrect, treats C: as host) - Now: file:///C:/path (correct per RFC 8089) 2. Deduplicate path resolution in fetchODataMetadata - Remove redundant file:// extraction and relative path resolution - Now relies on NormalizeURL() already called in createODataClient - Simplify to: PathFromURL() + os.ReadFile() - Remove unused mprDir parameter 3. Improve ServiceUrl validation error message - Add migration hint explaining the breaking change - Show how to create constant: CREATE CONSTANT Module.ApiLocation - Explain this enforces Mendix best practice 4. Fix unsafe os.Chdir in tests - Replace TestFetchODataMetadata_RelativePathWithoutProject - New test uses NormalizeURL() to generate file:// URL - No process-global state mutation - Tests now expect normalized URLs (matching production flow) All tests pass. Addresses review comments without changing functionality. Co-Authored-By: Claude Sonnet 4.5 --- internal/pathutil/uri.go | 8 ++++- mdl/executor/cmd_odata.go | 52 ++++++++++++-------------------- mdl/executor/cmd_odata_test.go | 55 ++++++++++++++-------------------- 3 files changed, 48 insertions(+), 67 deletions(-) diff --git a/internal/pathutil/uri.go b/internal/pathutil/uri.go index 77200792..7a6a8fa4 100644 --- a/internal/pathutil/uri.go +++ b/internal/pathutil/uri.go @@ -78,7 +78,13 @@ func NormalizeURL(rawURL string, baseDir string) (string, error) { } // Return as file:// URL with forward slashes (cross-platform) - return "file://" + filepath.ToSlash(absPath), nil + // 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. diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 1108d80c..3b2ad2cc 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -993,7 +993,13 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error { if stmt.ServiceUrl != "" { // ServiceUrl must be a constant reference (e.g., @Module.ConstantName) if !strings.HasPrefix(stmt.ServiceUrl, "@") { - return fmt.Errorf("ServiceUrl must be a constant reference starting with '@' (e.g., '@Module.LocationConstant'), got: %s", 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 @@ -1022,7 +1028,7 @@ func (e *Executor) createODataClient(stmt *ast.CreateODataClientStmt) error { } newSvc.MetadataUrl = normalizedUrl - metadata, hash, err := fetchODataMetadata(normalizedUrl, mprDir) + metadata, hash, err := fetchODataMetadata(normalizedUrl) if err != nil { fmt.Fprintf(e.output, "Warning: could not fetch $metadata: %v\n", err) } else if metadata != "" { @@ -1427,46 +1433,26 @@ func astEntityDefToModel(def *ast.PublishedEntityDef) (*model.PublishedEntityTyp // fetchODataMetadata downloads or reads the $metadata document. // Supports: // - https://... or http://... (HTTP fetch) -// - file:///abs/path (local absolute path) -// - ./path or path/file.xml (local relative path, resolved against mprDir) +// - 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. -// If mprDir is empty and a relative path is given, resolves against cwd. -func fetchODataMetadata(metadataUrl string, mprDir string) (metadata string, hash string, err error) { +// 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 } var body []byte - // Detect if this is a local file path (file:// or not http/https) - isHTTP := strings.HasPrefix(metadataUrl, "http://") || strings.HasPrefix(metadataUrl, "https://") + // 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) - if !isHTTP { - // Local file path - extract and resolve - filePath := metadataUrl - if strings.HasPrefix(metadataUrl, "file://") { - filePath = pathutil.URIToPath(metadataUrl) - if filePath == "" { - return "", "", fmt.Errorf("invalid file:// URI: %s", metadataUrl) - } - } - - // Resolve relative paths - if !filepath.IsAbs(filePath) { - if mprDir != "" { - filePath = filepath.Join(mprDir, filePath) - } else { - // No project loaded - use cwd - cwd, err := os.Getwd() - if err != nil { - return "", "", fmt.Errorf("failed to resolve relative path: %w", err) - } - filePath = filepath.Join(cwd, filePath) - } - } - - // Read local file + 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) diff --git a/mdl/executor/cmd_odata_test.go b/mdl/executor/cmd_odata_test.go index 048c73d5..9df6b8f0 100644 --- a/mdl/executor/cmd_odata_test.go +++ b/mdl/executor/cmd_odata_test.go @@ -20,41 +20,26 @@ func TestFetchODataMetadata_LocalFile(t *testing.T) { 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 - mprDir string wantErr bool errContains string }{ { - name: "absolute file:// URL", - url: "file://" + metadataPath, - mprDir: "", - wantErr: false, - }, - { - name: "absolute path without file://", - url: metadataPath, - mprDir: "", - wantErr: false, - }, - { - name: "relative path with mprDir", - url: "metadata.xml", - mprDir: tmpDir, - wantErr: false, - }, - { - name: "relative path with ./ prefix", - url: "./metadata.xml", - mprDir: tmpDir, + name: "RFC 8089 file:// URL", + url: fileURL, wantErr: false, }, { name: "nonexistent file", url: "file:///nonexistent/metadata.xml", - mprDir: "", wantErr: true, errContains: "failed to read local metadata file", }, @@ -62,7 +47,7 @@ func TestFetchODataMetadata_LocalFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - metadata, hash, err := fetchODataMetadata(tt.url, tt.mprDir) + metadata, hash, err := fetchODataMetadata(tt.url) if tt.wantErr { if err == nil { @@ -87,7 +72,7 @@ func TestFetchODataMetadata_LocalFile(t *testing.T) { } // Hash should be consistent - _, hash2, _ := fetchODataMetadata(tt.url, tt.mprDir) + _, hash2, _ := fetchODataMetadata(tt.url) if hash != hash2 { t.Errorf("Hash inconsistent between calls: %q vs %q", hash, hash2) } @@ -180,20 +165,24 @@ func TestNormalizeMetadataUrl(t *testing.T) { } } -func TestFetchODataMetadata_RelativePathWithoutProject(t *testing.T) { - // Create metadata file in current directory +func TestFetchODataMetadata_LocalFileAbsolute(t *testing.T) { + // Create metadata file with absolute path tmpDir := t.TempDir() - oldCwd, _ := os.Getwd() - defer os.Chdir(oldCwd) - os.Chdir(tmpDir) metadataContent := `` - if err := os.WriteFile("local.xml", []byte(metadataContent), 0644); err != nil { + 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) } - // Test with empty mprDir (should resolve against cwd) - metadata, hash, err := fetchODataMetadata("local.xml", "") + // 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) }