Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .claude/skills/mendix/browse-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 124 additions & 4 deletions .claude/skills/mendix/odata-data-sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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'
);

Expand Down Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 3 additions & 10 deletions cmd/mxcli/lsp_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
100 changes: 100 additions & 0 deletions internal/pathutil/uri.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading