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") + } +}