From 36a7f43841cb7b078860a57e156420676868dfd5 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:30:44 +0200 Subject: [PATCH 01/10] feat: add catalog search command for service discovery Implements Phase 1 of Catalog integration (#213): read-only search and discovery of services registered in Mendix Catalog. New command: mxcli catalog search [flags] Features: - Search with query term (required positional argument) - Filter by service type (OData, REST, SOAP) - Filter by environment (production-only flag) - Filter by ownership (owned-only flag) - Pagination support (limit, offset) - Dual output formats (table or JSON) - Table output: 7 columns, ~120 chars wide - Short UUIDs (first 8 chars) in table, full UUIDs in JSON Files added: - internal/catalog/types.go - API request/response structs - internal/catalog/client.go - HTTP client for catalog.mendix.com - cmd/mxcli/cmd_catalog.go - Cobra command definitions Reuses existing internal/auth infrastructure for PAT authentication. catalog.mendix.com already whitelisted in internal/auth/scheme.go. Phase 2 (client creation from Catalog) deferred pending architecture discussion on executor network access. Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 6 ++ cmd/mxcli/cmd_catalog.go | 142 +++++++++++++++++++++++++++++++++++++ internal/catalog/client.go | 86 ++++++++++++++++++++++ internal/catalog/types.go | 52 ++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 cmd/mxcli/cmd_catalog.go create mode 100644 internal/catalog/client.go create mode 100644 internal/catalog/types.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee5a3ef..906e9476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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 + +- **mxcli catalog search** — Search Mendix Catalog for data sources and services with filters for service type, environment, and ownership (#213) + ## [0.6.0] - 2026-04-09 ### Added diff --git a/cmd/mxcli/cmd_catalog.go b/cmd/mxcli/cmd_catalog.go new file mode 100644 index 00000000..e65773e1 --- /dev/null +++ b/cmd/mxcli/cmd_catalog.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "fmt" + "text/tabwriter" + + "github.com/mendixlabs/mxcli/internal/auth" + "github.com/mendixlabs/mxcli/internal/catalog" + "github.com/spf13/cobra" +) + +var catalogCmd = &cobra.Command{ + Use: "catalog", + Short: "Search and manage Mendix Catalog services", + Long: `Search for data sources and services registered in Mendix Catalog. + +Requires authentication via Personal Access Token (PAT). Create a PAT at: + https://user-settings.mendix.com/ + +Storage priority: + 1. MENDIX_PAT env var (set MXCLI_PROFILE to target a non-default profile) + 2. ~/.mxcli/auth.json (mode 0600)`, +} + +var catalogSearchCmd = &cobra.Command{ + Use: "search ", + Short: "Search for services in the Catalog", + Long: `Search for data sources and services in Mendix Catalog. + +Examples: + mxcli catalog search "customer" + mxcli catalog search "order" --service-type OData + mxcli catalog search "api" --production-only --json`, + Args: cobra.ExactArgs(1), + RunE: runCatalogSearch, +} + +func init() { + catalogSearchCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name") + catalogSearchCmd.Flags().String("service-type", "", "filter by service type (OData, REST, SOAP)") + catalogSearchCmd.Flags().Bool("production-only", false, "show only production endpoints") + catalogSearchCmd.Flags().Bool("owned-only", false, "show only owned services") + catalogSearchCmd.Flags().Int("limit", 20, "results per page (max 100)") + catalogSearchCmd.Flags().Int("offset", 0, "pagination offset") + catalogSearchCmd.Flags().Bool("json", false, "output as JSON array") + + catalogCmd.AddCommand(catalogSearchCmd) + rootCmd.AddCommand(catalogCmd) +} + +func runCatalogSearch(cmd *cobra.Command, args []string) error { + query := args[0] + profile, _ := cmd.Flags().GetString("profile") + serviceType, _ := cmd.Flags().GetString("service-type") + prodOnly, _ := cmd.Flags().GetBool("production-only") + ownedOnly, _ := cmd.Flags().GetBool("owned-only") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + asJSON, _ := cmd.Flags().GetBool("json") + + // Create client + client, err := catalog.NewClient(cmd.Context(), profile) + if err != nil { + if _, ok := err.(*auth.ErrNoCredential); ok { + return fmt.Errorf("no credential found. Run: mxcli auth login") + } + return err + } + + // Execute search + opts := catalog.SearchOptions{ + Query: query, + ServiceType: serviceType, + ProductionEndpointsOnly: prodOnly, + OwnedContentOnly: ownedOnly, + Limit: limit, + Offset: offset, + } + resp, err := client.Search(cmd.Context(), opts) + if err != nil { + if _, ok := err.(*auth.ErrUnauthenticated); ok { + return fmt.Errorf("authentication failed. Run: mxcli auth login") + } + return err + } + + // Output + if asJSON { + return outputJSON(cmd, resp.Data) + } + return outputTable(cmd, resp) +} + +func outputTable(cmd *cobra.Command, resp *catalog.SearchResponse) error { + if len(resp.Data) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No results found.") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tTYPE\tVERSION\tAPPLICATION\tENVIRONMENT\tPROD\tUUID") + + for _, item := range resp.Data { + name := truncate(item.Name, 22) + typ := truncate(item.ServiceType, 8) + version := truncate(item.Version, 10) + app := truncate(item.Application.Name, 20) + env := truncate(item.Environment.Type, 12) + prod := "" + if item.Environment.Type == "Production" { + prod = "Yes" + } + uuid := item.UUID + if len(uuid) >= 8 { + uuid = uuid[:8] // Short UUID + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + name, typ, version, app, env, prod, uuid) + } + + fmt.Fprintf(w, "\nTotal: %d results (showing %d-%d)\n", + resp.TotalResults, resp.Offset+1, resp.Offset+len(resp.Data)) + + return w.Flush() +} + +func outputJSON(cmd *cobra.Command, data []catalog.SearchResult) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(data) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/internal/catalog/client.go b/internal/catalog/client.go new file mode 100644 index 00000000..d2e5f377 --- /dev/null +++ b/internal/catalog/client.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/mendixlabs/mxcli/internal/auth" +) + +const baseURL = "https://catalog.mendix.com/rest/search/v5" + +// Client wraps the Catalog Search API with authentication. +type Client struct { + httpClient *http.Client + baseURL string +} + +// NewClient creates a new Catalog API client using the specified auth profile. +// The profile is resolved via internal/auth (env vars or ~/.mxcli/auth.json). +func NewClient(ctx context.Context, profile string) (*Client, error) { + httpClient, err := auth.ClientFor(ctx, profile) + if err != nil { + return nil, err + } + return &Client{ + httpClient: httpClient, + baseURL: baseURL, + }, nil +} + +// Search executes a catalog search with the given options. +// Calls GET /data with query parameters and returns parsed results. +func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchResponse, error) { + // Build query params + params := url.Values{} + if opts.Query != "" { + params.Set("query", opts.Query) + } + if opts.ServiceType != "" { + params.Set("serviceType", opts.ServiceType) + } + if opts.ProductionEndpointsOnly { + params.Set("productionEndpointsOnly", "true") + } + if opts.OwnedContentOnly { + params.Set("ownedContentOnly", "true") + } + if opts.Limit > 0 { + params.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Offset > 0 { + params.Set("offset", strconv.Itoa(opts.Offset)) + } + + // Make request + reqURL := c.baseURL + "/data?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + // auth.authTransport wraps 401/403 as auth.ErrUnauthenticated + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("catalog API returned status %d", resp.StatusCode) + } + + var result SearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &result, nil +} diff --git a/internal/catalog/types.go b/internal/catalog/types.go new file mode 100644 index 00000000..b0391aa0 --- /dev/null +++ b/internal/catalog/types.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +// SearchOptions contains parameters for catalog search requests. +type SearchOptions struct { + Query string + ServiceType string // "OData", "REST", "SOAP", "" (all) + ProductionEndpointsOnly bool + OwnedContentOnly bool + Limit int + Offset int +} + +// SearchResponse represents the response from GET /data endpoint. +type SearchResponse struct { + Data []SearchResult `json:"data"` + TotalResults int `json:"totalResults"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// SearchResult represents a single data source/service in the catalog. +type SearchResult struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + ServiceType string `json:"serviceType"` + Environment Environment `json:"environment"` + Application Application `json:"application"` + SecurityClassification string `json:"securityClassification"` + LastUpdated string `json:"lastUpdated"` + Validated bool `json:"validated"` +} + +// Environment represents the deployment environment of a service. +type Environment struct { + Name string `json:"name"` + Location string `json:"location"` + Type string `json:"type"` // "Production", "Acceptance", "Test" + UUID string `json:"uuid"` +} + +// Application represents the Mendix app hosting a service. +type Application struct { + Name string `json:"name"` + Description string `json:"description"` + UUID string `json:"uuid"` + BusinessOwner string `json:"businessOwner"` + TechnicalOwner string `json:"technicalOwner"` +} From 0b65a70a31ef49a9ed0ef164ab87d9d416720c41 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:30:54 +0200 Subject: [PATCH 02/10] docs: add Catalog integration proposal Comprehensive proposal for mxcli Catalog integration covering: - Phase 1: Search and discovery (implemented in this PR) - Phase 2: Client creation from Catalog (architecture TBD) - API analysis and endpoint details - Three architectural options for executor integration - Trade-offs and design decisions References issue #213. Co-Authored-By: Claude Sonnet 4.5 --- .../PROPOSAL_catalog_integration.md | 784 ++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 docs/11-proposals/PROPOSAL_catalog_integration.md diff --git a/docs/11-proposals/PROPOSAL_catalog_integration.md b/docs/11-proposals/PROPOSAL_catalog_integration.md new file mode 100644 index 00000000..04307e76 --- /dev/null +++ b/docs/11-proposals/PROPOSAL_catalog_integration.md @@ -0,0 +1,784 @@ +# Proposal: `mxcli catalog` — Mendix Catalog Integration + +**Status:** Draft +**Date:** 2026-04-16 +**Author:** Generated with Claude Code + +## Problem + +Mendix Catalog (catalog.mendix.com) is the centralized registry for discovering data sources and services across an organization's landscape. It indexes OData services, REST APIs, SOAP services, and Business Events published by Mendix applications and external systems. + +Currently, users must: +1. **Manually browse the Catalog web portal** to discover available services before consuming them +2. **Copy service URLs and metadata paths** into Studio Pro or MDL scripts by hand +3. **Lack scriptable access** for CI/CD pipelines that need to validate service availability or generate reports + +This creates friction in two areas: + +1. **Development workflow** — Developers waste time navigating the portal UI to find services. No quick CLI lookup exists for "what customer data services are available in Production?" +2. **Automation gaps** — CI/CD pipelines cannot query the Catalog programmatically to validate that a required service exists before attempting to deploy a consuming application. + +### Future Goal (Out of Scope for This PR) + +Once discovery is implemented, a natural follow-up is **automatic OData client generation from Catalog entries**: + +```bash +mxcli catalog create-odata-client --into MyModule +``` + +This would fetch metadata from the Catalog-registered endpoint and execute `CREATE EXTERNAL ENTITIES` automatically. However, this proposal focuses solely on **read-only search and discovery** to unblock manual workflows first. + +## API Discovery + +**Base URL:** `https://catalog.mendix.com/rest/search/v5` + +**OpenAPI Spec:** + +**Auth:** `Authorization: MxToken ` (same as marketplace-api.mendix.com). PATs are created at (Developer Settings → Personal Access Tokens). + +**Host Whitelisting:** `catalog.mendix.com` is already in `internal/auth/scheme.go` hostSchemes map since the platform auth spike (2026-04-14). No auth infrastructure changes needed. + +### Key Endpoints + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/data` | GET | Search catalog with filters (query, serviceType, environment, ownership) | +| `/endpoints/{EndpointUUID}` | GET | Retrieve detailed endpoint metadata (entities, actions, contract) | + +### GET /data — Search Endpoint + +**Query Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `query` | string | No* | - | Search term (min 3 alphanumeric chars). *Spec says "Yes" but API returns all results if omitted. | +| `serviceType` | string | No | all | Filter by protocol: `OData`, `REST`, `SOAP` | +| `productionEndpointsOnly` | boolean | No | false | Show only Production environments | +| `ownedContentOnly` | boolean | No | false | Show only services where user is business/technical owner | +| `capabilities` | string | No | - | Comma-delimited capabilities (e.g., "updatable"). Combined with AND. | +| `limit` | integer | No | 20 | Results per page (max: 100) | +| `offset` | integer | No | 0 | Zero-based pagination offset | + +**Response Schema (200 OK):** + +```jsonc +{ + "data": [ + { + "uuid": "a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f", + "name": "CustomerService", + "version": "1.2.0", + "description": "Manages customer data and relationships", + "serviceType": "OData", // or "REST", "SOAP", "Business Events" + "environment": { + "name": "Production", + "location": "EU", + "type": "Production", // or "Acceptance", "Test" + "uuid": "..." + }, + "application": { + "name": "CRM Application", + "description": "...", + "uuid": "...", + "businessOwner": "...", + "technicalOwner": "..." + }, + "securityClassification": "Public", // or "Internal", "Confidential" + "validated": true, + "lastUpdated": "2026-04-10T14:32:00Z", + "tags": ["customer", "crm"], + "entities": [...], // OData only: array of entity metadata + "actions": [...] // OData only: array of action metadata + } + ], + "totalResults": 42, + "limit": 20, + "offset": 0, + "links": [...] // pagination links +} +``` + +### GET /endpoints/{EndpointUUID} — Endpoint Details + +Returns full metadata for a single endpoint, including: +- Complete entity/action definitions (OData) +- Resource paths and operations (REST) +- WSDL reference (SOAP) +- Contract details (Business Events) + +**Critical detail:** The metadata content is **embedded in the response JSON**, not at a separate URL: + +```json +{ + "uuid": "9e26c386-9316-4a33-9963-8fe9f69a5117", + "serviceVersion": { + "contracts": [ + { + "type": "CSDL", + "specificationVersion": "3.0", + "documents": [ + { + "isPrimary": true, + "uri": "metadata.xml", + "contents": "..." + } + ] + } + ], + "entities": [...], + "actions": [...] + } +} +``` + +The `serviceVersion.contracts[0].documents[0].contents` field contains the complete XML metadata (for OData) or OpenAPI spec (for REST). This means: +- Users **cannot** simply copy a URL and paste into MDL (metadata requires auth to fetch) +- Any "create client from Catalog" feature must handle the API call, either in CLI or executor +- The metadata can be extracted and written to a local file for MDL consumption + +**Future PR will use this for `mxcli catalog show ` and client creation commands.** + +## Proposed Command Interface + +### `mxcli catalog search [flags]` + +**Synopsis:** +```bash +mxcli catalog search [flags] +``` + +**Arguments:** +- `` — Required positional argument. Search term (min 3 chars recommended by API). + +**Flags:** +- `--profile ` — Auth profile (default: "default") +- `--service-type ` — Filter by protocol: `OData`, `REST`, `SOAP` +- `--production-only` — Show only Production environment endpoints +- `--owned-only` — Show only services where user is owner +- `--limit ` — Results per page (default: 20, max: 100) +- `--offset ` — Pagination offset (default: 0) +- `--json` — Output as JSON array instead of table + +**Examples:** + +```bash +# Authenticate first (one-time) +mxcli auth login + +# Basic search +mxcli catalog search "customer" + +# Filter by service type +mxcli catalog search "customer" --service-type OData + +# Production endpoints only +mxcli catalog search "inventory" --production-only + +# JSON output for scripting +mxcli catalog search "order" --json | jq '.[] | {name, uuid, type}' + +# Pagination +mxcli catalog search "api" --limit 10 --offset 20 + +# Owned services only +mxcli catalog search "sales" --owned-only +``` + +### Table Output Format + +**Design Decision:** 7 columns, ~120 chars wide (fits standard terminal width). + +``` +NAME TYPE VERSION APPLICATION ENVIRONMENT PROD UUID +CustomerService OData 1.2.0 CRM Application Production Yes a7f3c2d1 +OrderAPI REST 2.0.1 E-commerce Platform Acceptance No b8e4d3e2 +InventorySync SOAP 1.0.0 Warehouse System Test No c9f5e4f3 +``` + +**Column Widths:** +- NAME (22 chars) — Truncate with "..." if longer +- TYPE (8 chars) — OData, REST, SOAP, BusEvt +- VERSION (10 chars) — As returned by API +- APPLICATION (20 chars) — Truncate with "..." if longer +- ENVIRONMENT (12 chars) — Type field (Production, Acceptance, Test) +- PROD (4 chars) — "Yes" if environment.Type == "Production", blank otherwise +- UUID (8 chars) — First 8 chars only (full UUID available in --json mode) + +**Rationale:** +- Short UUIDs reduce cognitive load while remaining unique enough for manual lookup +- PROD column provides at-a-glance production status without reading ENVIRONMENT +- APPLICATION provides context without requiring a separate lookup +- Full details available via `--json` for scripting use cases + +### JSON Output Format + +JSON mode outputs the raw `data` array from the API response: + +```jsonc +[ + { + "uuid": "a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f", + "name": "CustomerService", + "version": "1.2.0", + "serviceType": "OData", + "environment": { ... }, + "application": { ... }, + // ... all other fields + }, + // ... +] +``` + +This enables scripting workflows: + +```bash +# Get UUIDs of all production OData services +mxcli catalog search "customer" --service-type OData --production-only --json \ + | jq -r '.[] | .uuid' + +# Generate markdown report +mxcli catalog search "api" --json \ + | jq -r '.[] | "- [\(.name)](\(.application.name)) - \(.description)"' +``` + +## Implementation Plan + +### 1. File Structure + +Create three new files following the existing `internal/auth` + `cmd/mxcli/cmd_*.go` pattern: + +``` +internal/catalog/types.go # API request/response structs +internal/catalog/client.go # HTTP client wrapping catalog.mendix.com/rest/search/v5 +cmd/mxcli/cmd_catalog.go # Cobra commands and RunE handlers +``` + +### 2. API Types (`internal/catalog/types.go`) + +```go +package catalog + +type SearchOptions struct { + Query string + ServiceType string // "OData", "REST", "SOAP", "" (all) + ProductionEndpointsOnly bool + OwnedContentOnly bool + Limit int + Offset int +} + +type SearchResponse struct { + Data []SearchResult `json:"data"` + TotalResults int `json:"totalResults"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type SearchResult struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + ServiceType string `json:"serviceType"` + Environment Environment `json:"environment"` + Application Application `json:"application"` + SecurityClassification string `json:"securityClassification"` + Validated bool `json:"validated"` + LastUpdated string `json:"lastUpdated"` +} + +type Environment struct { + Name string `json:"name"` + Location string `json:"location"` + Type string `json:"type"` // "Production", "Acceptance", "Test" + UUID string `json:"uuid"` +} + +type Application struct { + Name string `json:"name"` + Description string `json:"description"` + UUID string `json:"uuid"` + BusinessOwner string `json:"businessOwner"` + TechnicalOwner string `json:"technicalOwner"` +} +``` + +### 3. HTTP Client (`internal/catalog/client.go`) + +**Pattern:** Reuse `internal/auth/client.go` authentication transport. + +```go +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/mendixlabs/mxcli/internal/auth" +) + +const baseURL = "https://catalog.mendix.com/rest/search/v5" + +type Client struct { + httpClient *http.Client + baseURL string +} + +func NewClient(ctx context.Context, profile string) (*Client, error) { + httpClient, err := auth.ClientFor(ctx, profile) + if err != nil { + return nil, err + } + return &Client{ + httpClient: httpClient, + baseURL: baseURL, + }, nil +} + +func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchResponse, error) { + // Build query params + params := url.Values{} + if opts.Query != "" { + params.Set("query", opts.Query) + } + if opts.ServiceType != "" { + params.Set("serviceType", opts.ServiceType) + } + if opts.ProductionEndpointsOnly { + params.Set("productionEndpointsOnly", "true") + } + if opts.OwnedContentOnly { + params.Set("ownedContentOnly", "true") + } + if opts.Limit > 0 { + params.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Offset > 0 { + params.Set("offset", strconv.Itoa(opts.Offset)) + } + + // Make request + reqURL := c.baseURL + "/data?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + // auth.authTransport wraps 401/403 as auth.ErrUnauthenticated + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("catalog API returned status %d", resp.StatusCode) + } + + var result SearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &result, nil +} +``` + +**Error Handling:** +- `auth.ClientFor()` returns `auth.ErrNoCredential` if no PAT is stored → command wraps with "Run: mxcli auth login" +- `auth.authTransport` wraps 401/403 as `auth.ErrUnauthenticated` → command wraps with "Authentication failed. Run: mxcli auth login" +- Non-200 responses return status code → command wraps with "Catalog API error" + +### 4. CLI Command (`cmd/mxcli/cmd_catalog.go`) + +**Pattern:** Follow `cmd/mxcli/cmd_auth.go` structure (Cobra command, flags, tabwriter/JSON output). + +```go +package main + +import ( + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + + "github.com/mendixlabs/mxcli/internal/auth" + "github.com/mendixlabs/mxcli/internal/catalog" + "github.com/spf13/cobra" +) + +var catalogCmd = &cobra.Command{ + Use: "catalog", + Short: "Search and manage Mendix Catalog services", + Long: `Search for data sources and services registered in Mendix Catalog. + +Requires authentication via Personal Access Token (PAT). Create a PAT at: + https://user-settings.mendix.com/ + +Storage priority: + 1. MENDIX_PAT env var (set MXCLI_PROFILE to target a non-default profile) + 2. ~/.mxcli/auth.json (mode 0600)`, +} + +var catalogSearchCmd = &cobra.Command{ + Use: "search ", + Short: "Search for services in the Catalog", + Long: `Search for data sources and services in Mendix Catalog. + +Examples: + mxcli catalog search "customer" + mxcli catalog search "order" --service-type OData + mxcli catalog search "api" --production-only --json`, + Args: cobra.ExactArgs(1), + RunE: runCatalogSearch, +} + +func init() { + catalogSearchCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name") + catalogSearchCmd.Flags().String("service-type", "", "filter by service type (OData, REST, SOAP)") + catalogSearchCmd.Flags().Bool("production-only", false, "show only production endpoints") + catalogSearchCmd.Flags().Bool("owned-only", false, "show only owned services") + catalogSearchCmd.Flags().Int("limit", 20, "results per page (max 100)") + catalogSearchCmd.Flags().Int("offset", 0, "pagination offset") + catalogSearchCmd.Flags().Bool("json", false, "output as JSON array") + + catalogCmd.AddCommand(catalogSearchCmd) + rootCmd.AddCommand(catalogCmd) +} + +func runCatalogSearch(cmd *cobra.Command, args []string) error { + query := args[0] + profile, _ := cmd.Flags().GetString("profile") + serviceType, _ := cmd.Flags().GetString("service-type") + prodOnly, _ := cmd.Flags().GetBool("production-only") + ownedOnly, _ := cmd.Flags().GetBool("owned-only") + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + asJSON, _ := cmd.Flags().GetBool("json") + + // Create client + client, err := catalog.NewClient(cmd.Context(), profile) + if err != nil { + if _, ok := err.(*auth.ErrNoCredential); ok { + return fmt.Errorf("no credential found. Run: mxcli auth login") + } + return err + } + + // Execute search + opts := catalog.SearchOptions{ + Query: query, + ServiceType: serviceType, + ProductionEndpointsOnly: prodOnly, + OwnedContentOnly: ownedOnly, + Limit: limit, + Offset: offset, + } + resp, err := client.Search(cmd.Context(), opts) + if err != nil { + if _, ok := err.(*auth.ErrUnauthenticated); ok { + return fmt.Errorf("authentication failed. Run: mxcli auth login") + } + return err + } + + // Output + if asJSON { + return outputJSON(cmd, resp.Data) + } + return outputTable(cmd, resp) +} + +func outputTable(cmd *cobra.Command, resp *catalog.SearchResponse) error { + if len(resp.Data) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No results found.") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tTYPE\tVERSION\tAPPLICATION\tENVIRONMENT\tPROD\tUUID") + + for _, item := range resp.Data { + name := truncate(item.Name, 22) + typ := truncate(item.ServiceType, 8) + version := truncate(item.Version, 10) + app := truncate(item.Application.Name, 20) + env := truncate(item.Environment.Type, 12) + prod := "" + if item.Environment.Type == "Production" { + prod = "Yes" + } + uuid := item.UUID[:8] // Short UUID + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + name, typ, version, app, env, prod, uuid) + } + + fmt.Fprintf(w, "\nTotal: %d results (showing %d-%d)\n", + resp.TotalResults, resp.Offset+1, resp.Offset+len(resp.Data)) + + return w.Flush() +} + +func outputJSON(cmd *cobra.Command, data []catalog.SearchResult) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(data) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} +``` + +### 5. Testing Strategy + +**Unit Tests:** + +1. **`internal/catalog/types_test.go`** — JSON unmarshaling + - Valid API response → SearchResponse struct + - Missing optional fields handled gracefully + +2. **`internal/catalog/client_test.go`** — HTTP client + - `httptest.Server` mocking API responses + - Query param encoding + - Error handling (401, 403, 500, network errors) + +3. **`cmd/mxcli/cmd_catalog_test.go`** — Command integration + - Flag parsing + - Table output formatting + - JSON output formatting + - Error message wrapping + +**Manual Testing:** + +```bash +# Prerequisites +mxcli auth login + +# Basic search +mxcli catalog search "customer" + +# Filters +mxcli catalog search "api" --service-type OData +mxcli catalog search "data" --production-only +mxcli catalog search "service" --owned-only + +# Pagination +mxcli catalog search "test" --limit 5 +mxcli catalog search "test" --limit 5 --offset 5 + +# JSON output +mxcli catalog search "order" --json | jq '.[] | .name' + +# Error cases +mxcli auth logout +mxcli catalog search "test" # Should error: "Run: mxcli auth login" +``` + +## Trade-offs & Design Decisions + +### 1. Query Parameter: Required or Optional? + +**API Spec:** Marks `query` as required, but actual API returns all results if omitted. + +**Decision:** Make query a **required positional argument** in CLI for clarity: +- `mxcli catalog search "customer"` is intuitive +- Forces users to think about what they're searching for (avoids accidental full-catalog dumps) +- Matches common CLI patterns (grep, gh, curl) + +**Alternative:** Make query optional and default to empty (list all). Rejected because: +- Returning 1000+ results by default is poor UX +- Users can specify `--limit 100` if they want a large result set + +### 2. UUID Display: Full or Short? + +**Decision:** Short (8 chars) in table, full in JSON. + +**Rationale:** +- 36-char UUIDs consume significant column width +- First 8 chars are usually unique enough for manual lookup +- Users needing full UUIDs for automation can use `--json` mode +- Matches common CLI patterns (git log --oneline, docker ps) + +### 3. Table vs. JSON Default Output + +**Decision:** Table by default, JSON via `--json` flag. + +**Rationale:** +- Human users expect readable tables (matches `mxcli auth status`, `gh pr list`) +- Automation uses `--json` (explicit opt-in prevents breaking changes to table format) +- JSON output includes all fields; table shows curated subset + +### 4. No Caching + +**Decision:** No client-side caching. Every command makes a fresh API call. + +**Rationale:** +- Catalog data changes frequently (new deployments, environment changes) +- Stale cache could mislead users about service availability +- API response times are acceptable (<500ms observed) +- Caching adds complexity (TTL, invalidation, storage) + +**Future:** If performance becomes an issue, add opt-in cache (`--cache` flag with TTL). + +### 5. Pagination: Manual vs. Auto + +**Decision:** Expose `--limit` and `--offset` flags. No auto-pagination. + +**Rationale:** +- Simple implementation (no "press any key for next page" logic) +- Predictable for scripting (no interactive prompts in CI) +- Users can pipe to `less` for paging: `mxcli catalog search "api" --limit 100 | less` + +**Future:** Add interactive pagination with arrow keys (bubble tea TUI) as a separate mode. + +## Architectural Discussion: Client Creation from Catalog + +The embedded metadata in `/endpoints/{uuid}` responses creates an architectural question for future client creation features. + +### Key Constraint + +The Catalog API returns metadata **embedded in JSON**, not at a public URL: +- `serviceVersion.contracts[0].documents[0].contents` contains the full XML/OpenAPI spec +- Metadata requires auth (PAT token) to fetch +- Users cannot simply copy a URL into MDL scripts + +### Three Architectural Options + +#### Option A: MDL-First (Executor Integration) + +Extend MDL grammar to support Catalog references: + +```sql +CREATE EXTERNAL ENTITIES FROM CATALOG 'a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f' INTO MyModule; +``` + +**Flow:** Parser → Executor calls `catalog.Client.GetEndpoint(uuid)` → Extracts metadata XML → Parses → Creates entities + +**Pros:** Consistent with MDL philosophy, works in REPL/scripts, single syntax +**Cons:** Executor becomes network-aware (auth, latency, errors), grammar change, REPL sessions need credentials + +#### Option B: CLI Wrapper (No Executor Changes) + +CLI command fetches metadata and executes MDL: + +```bash +mxcli catalog create-odata-client a7f3c2d1 --into MyModule -p app.mpr +``` + +**Flow:** CLI → Fetch metadata → Write temp file → Execute `CREATE EXTERNAL ENTITIES FROM '/tmp/...' INTO MyModule` + +**Pros:** No executor changes, auth stays in CLI, quick to implement +**Cons:** Not usable in REPL, two ways to create clients, temp file management + +#### Option C: CLI Helper (Metadata Export) + +CLI exports metadata, user runs MDL separately: + +```bash +mxcli catalog export-metadata a7f3c2d1 --output metadata.xml +# Then in MDL: +CREATE EXTERNAL ENTITIES FROM 'metadata.xml' INTO MyModule; +``` + +**Flow:** CLI → Fetch → Write user-specified file → User executes MDL + +**Pros:** Clear separation, no executor changes, explicit workflow, metadata visible/versionable +**Cons:** Two-step workflow, manual step, metadata staleness + +### Recommendation + +**Phase 1 (this PR):** Implement search only, defer architecture decision. + +**Phase 2:** Prototype both Option A and Option C to compare UX: +- Option A for integrated workflow (test executor network integration) +- Option C for explicit workflow (test two-step UX) + +Choose based on user feedback and implementation complexity. + +## Future Enhancements (Out of Scope) + +### 1. `mxcli catalog show ` + +Show detailed endpoint metadata: + +```bash +mxcli catalog show a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f + +# Output: +Name: CustomerService +Type: OData +Version: 1.2.0 +Application: CRM Application +Environment: Production (EU) +Description: Manages customer data and relationships + +Entities (3): + - Customer (attributes: Name, Email, Phone) + - Order (attributes: OrderNumber, Date, TotalAmount) + - Address (attributes: Street, City, PostalCode) + +Actions (2): + - CalculateDiscount + - ValidateCustomer +``` + +**Implementation:** Call `GET /endpoints/{uuid}`, parse entities/actions, format as hierarchical output. + +### 2. `mxcli catalog create-odata-client ` + +Generate OData client from Catalog entry: + +```bash +mxcli catalog create-odata-client a7f3c2d1 --into MyModule -p app.mpr + +# Equivalent to: +# 1. GET /endpoints/a7f3c2d1 → extract metadata URL +# 2. CREATE EXTERNAL ENTITIES FROM 'http://...$metadata' INTO MyModule +``` + +**Implementation:** Fetch endpoint details, extract metadata URL, call existing `CREATE EXTERNAL ENTITIES` executor. + +### 3. Interactive Search UI + +TUI with arrow-key navigation and fuzzy search: + +```bash +mxcli catalog search --interactive + +# Opens bubble tea UI: +┌─────────────────────────────────────────────────────────────┐ +│ Search: customer▊ │ +├─────────────────────────────────────────────────────────────┤ +│ > CustomerService (OData 1.2.0) - CRM Application │ +│ CustomerAPI (REST 2.0.1) - E-commerce Platform │ +│ CustomerData (OData 1.0.0) - Legacy System │ +└─────────────────────────────────────────────────────────────┘ +Press Enter to view details, Esc to exit +``` + +**Implementation:** Use [bubble tea](https://github.com/charmbracelet/bubbletea) framework (already used in mxcli TUI). Reuse `catalog.Client.Search()`. + +## CHANGELOG Entry + +```markdown +### Added + +- **mxcli catalog search** — Search Mendix Catalog for data sources and services with filters for service type, environment, and ownership (#XXX) +``` + +## References + +- OpenAPI Spec: +- Mendix Catalog Docs: +- Platform Auth Proposal: `docs/11-proposals/PROPOSAL_platform_auth.md` +- Auth Implementation: `internal/auth/`, `cmd/mxcli/cmd_auth.go` From 4c11ceade1c55a86aff9964b187ed9910e26babc Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:35:02 +0200 Subject: [PATCH 03/10] chore: apply go fmt to existing files Automatic formatting changes from go fmt. Co-Authored-By: Claude Sonnet 4.5 --- .claude/skills/mendix/catalog-search.md | 148 ++++++++++++++++++++++++ cmd/mxcli/docker/detect.go | 8 +- internal/catalog/client_test.go | 143 +++++++++++++++++++++++ internal/catalog/types_test.go | 97 ++++++++++++++++ mdl/ast/ast_entity.go | 62 +++++----- mdl/ast/ast_microflow.go | 10 +- mdl/ast/ast_query.go | 2 +- mdl/ast/ast_rest.go | 18 +-- mdl/executor/cmd_rest_clients.go | 4 +- mdl/executor/cmd_security.go | 1 - model/types.go | 22 ++-- sdk/agenteditor/types.go | 28 ++--- sdk/microflows/microflows_actions.go | 10 +- sdk/mpr/parser_customblob.go | 36 +++--- sdk/mpr/writer_datatransformer.go | 4 +- sdk/mpr/writer_rest.go | 52 ++++----- sdk/pages/pages_datasources.go | 2 +- 17 files changed, 517 insertions(+), 130 deletions(-) create mode 100644 .claude/skills/mendix/catalog-search.md create mode 100644 internal/catalog/client_test.go create mode 100644 internal/catalog/types_test.go diff --git a/.claude/skills/mendix/catalog-search.md b/.claude/skills/mendix/catalog-search.md new file mode 100644 index 00000000..ca4531f0 --- /dev/null +++ b/.claude/skills/mendix/catalog-search.md @@ -0,0 +1,148 @@ +# Catalog Search + +Search and discover services registered in Mendix Catalog programmatically. + +## Authentication Required + +Catalog search requires a Personal Access Token (PAT): + +```bash +# One-time setup +mxcli auth login +``` + +Create a PAT at: https://user-settings.mendix.com/ (Developer Settings → Personal Access Tokens) + +## Basic Usage + +```bash +# Search for services +mxcli catalog search "customer" + +# Filter by service type +mxcli catalog search "order" --service-type OData +mxcli catalog search "api" --service-type REST + +# Production endpoints only +mxcli catalog search "inventory" --production-only + +# Services you own +mxcli catalog search "sales" --owned-only + +# JSON output for scripting +mxcli catalog search "data" --json | jq '.[] | {name, uuid, type}' +``` + +## Output Formats + +**Table (default):** +``` +NAME TYPE VERSION APPLICATION ENVIRONMENT PROD UUID +CustomerService OData 1.2.0 CRM Application Production Yes a7f3c2d1 +OrderAPI REST 2.0.1 E-commerce Platform Acceptance No b8e4d3e2 +InventorySync SOAP 1.0.0 Warehouse System Test No c9f5e4f3 + +Total: 42 results (showing 1-3) +``` + +- **NAME**: Service name (truncated if > 22 chars) +- **TYPE**: OData, REST, SOAP +- **VERSION**: Service version +- **APPLICATION**: Hosting application name +- **ENVIRONMENT**: Production, Acceptance, Test +- **PROD**: "Yes" if production, blank otherwise +- **UUID**: First 8 characters (full UUID in JSON mode) + +**JSON mode:** +```bash +mxcli catalog search "customer" --json +``` + +Returns full endpoint details including: +- Complete UUIDs +- Descriptions +- Security classification +- Last updated timestamp +- Entity and action metadata (for OData) + +## Pagination + +```bash +# First 10 results +mxcli catalog search "api" --limit 10 + +# Next 10 results +mxcli catalog search "api" --limit 10 --offset 10 + +# Maximum 100 per request +mxcli catalog search "service" --limit 100 +``` + +## Common Use Cases + +**Find production OData services:** +```bash +mxcli catalog search "customer" --service-type OData --production-only +``` + +**Get UUIDs for automation:** +```bash +mxcli catalog search "order" --json | jq -r '.[] | .uuid' +``` + +**Generate service inventory report:** +```bash +mxcli catalog search "api" --json | \ + jq -r '.[] | "\(.name) (\(.serviceType)) - \(.application.name)"' +``` + +**Filter by multiple criteria:** +```bash +mxcli catalog search "data" \ + --service-type OData \ + --production-only \ + --limit 50 +``` + +## Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--profile` | string | "default" | Auth profile name | +| `--service-type` | string | (all) | Filter by OData, REST, or SOAP | +| `--production-only` | bool | false | Show only production endpoints | +| `--owned-only` | bool | false | Show only owned services | +| `--limit` | int | 20 | Results per page (max 100) | +| `--offset` | int | 0 | Pagination offset | +| `--json` | bool | false | Output as JSON array | + +## Error Handling + +**No credential:** +``` +Error: no credential found. Run: mxcli auth login +``` + +**Authentication failed:** +``` +Error: authentication failed. Run: mxcli auth login +``` + +Solution: Log in with a valid PAT. + +**Network errors:** +Catalog API requires internet connectivity. Check network connection and firewall settings. + +## Future Features + +Phase 2 (not yet implemented): +- `mxcli catalog show ` - Display detailed endpoint metadata +- `mxcli catalog create-odata-client ` - Generate OData client from Catalog entry +- Interactive search UI with arrow-key navigation + +See GitHub issue #213 for architecture discussion. + +## Related + +- Platform authentication: `.claude/skills/mendix/platform-auth.md` +- OData client creation: `.claude/skills/mendix/odata-data-sharing.md` diff --git a/cmd/mxcli/docker/detect.go b/cmd/mxcli/docker/detect.go index a31428c9..9c606f96 100644 --- a/cmd/mxcli/docker/detect.go +++ b/cmd/mxcli/docker/detect.go @@ -13,10 +13,10 @@ // CDN downloads are the primary source for mxbuild and runtime. // // Resolution priority (all platforms): -// 1. Explicit path (--mxbuild-path) -// 2. PATH lookup -// 3. OS-specific known locations (Studio Pro on Windows) -// 4. Cached CDN downloads (~/.mxcli/mxbuild/) +// 1. Explicit path (--mxbuild-path) +// 2. PATH lookup +// 3. OS-specific known locations (Studio Pro on Windows) +// 4. Cached CDN downloads (~/.mxcli/mxbuild/) // // Path discovery on Windows must NOT hardcode drive letters. Use environment // variables (PROGRAMFILES, PROGRAMW6432, SystemDrive) to locate install dirs. diff --git a/internal/catalog/client_test.go b/internal/catalog/client_test.go new file mode 100644 index 00000000..6558a8e4 --- /dev/null +++ b/internal/catalog/client_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mendixlabs/mxcli/internal/auth" +) + +func TestClient_Search(t *testing.T) { + // Mock server returning search results + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/data" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Method != "GET" { + t.Errorf("unexpected method: %s", r.Method) + } + + // Check query params + query := r.URL.Query() + if q := query.Get("query"); q != "test" { + t.Errorf("expected query=test, got %s", q) + } + if st := query.Get("serviceType"); st != "OData" { + t.Errorf("expected serviceType=OData, got %s", st) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "data": [ + { + "uuid": "test-uuid", + "name": "TestService", + "version": "1.0.0", + "serviceType": "OData", + "environment": {"name": "Test", "type": "Test"}, + "application": {"name": "TestApp"} + } + ], + "totalResults": 1, + "limit": 20, + "offset": 0 + }`)) + })) + defer server.Close() + + // Create client with mock HTTP client (no auth) + client := &Client{ + httpClient: server.Client(), + baseURL: server.URL, + } + + // Execute search + ctx := context.Background() + opts := SearchOptions{ + Query: "test", + ServiceType: "OData", + Limit: 20, + } + resp, err := client.Search(ctx, opts) + if err != nil { + t.Fatalf("search failed: %v", err) + } + + if resp.TotalResults != 1 { + t.Errorf("expected 1 result, got %d", resp.TotalResults) + } + if len(resp.Data) != 1 { + t.Fatalf("expected 1 data item, got %d", len(resp.Data)) + } + if resp.Data[0].Name != "TestService" { + t.Errorf("unexpected service name: %s", resp.Data[0].Name) + } +} + +func TestClient_Search_EmptyQuery(t *testing.T) { + // Mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check that query param is absent when empty + query := r.URL.Query() + if _, exists := query["query"]; exists { + t.Error("expected query param to be absent when empty") + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"data": [], "totalResults": 0, "limit": 20, "offset": 0}`)) + })) + defer server.Close() + + client := &Client{ + httpClient: server.Client(), + baseURL: server.URL, + } + + ctx := context.Background() + opts := SearchOptions{} // Empty query + resp, err := client.Search(ctx, opts) + if err != nil { + t.Fatalf("search failed: %v", err) + } + + if resp.TotalResults != 0 { + t.Errorf("expected 0 results, got %d", resp.TotalResults) + } +} + +func TestClient_Search_HTTPError(t *testing.T) { + // Mock server returning error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := &Client{ + httpClient: server.Client(), + baseURL: server.URL, + } + + ctx := context.Background() + opts := SearchOptions{Query: "test"} + _, err := client.Search(ctx, opts) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestNewClient_NoCredential(t *testing.T) { + ctx := context.Background() + _, err := NewClient(ctx, "nonexistent-profile") + if err == nil { + t.Fatal("expected error when no credential found") + } + if _, ok := err.(*auth.ErrNoCredential); !ok { + t.Errorf("expected ErrNoCredential, got %T: %v", err, err) + } +} diff --git a/internal/catalog/types_test.go b/internal/catalog/types_test.go new file mode 100644 index 00000000..21f3bbae --- /dev/null +++ b/internal/catalog/types_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "encoding/json" + "testing" +) + +func TestSearchResponse_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "data": [ + { + "uuid": "a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f", + "name": "CustomerService", + "version": "1.2.0", + "description": "Customer data service", + "serviceType": "OData", + "environment": { + "name": "Production", + "location": "EU", + "type": "Production", + "uuid": "env-uuid" + }, + "application": { + "name": "CRM App", + "description": "CRM system", + "uuid": "app-uuid", + "businessOwner": "owner@example.com", + "technicalOwner": "tech@example.com" + }, + "securityClassification": "Internal", + "lastUpdated": "2026-04-16T10:00:00Z", + "validated": true + } + ], + "totalResults": 42, + "limit": 20, + "offset": 0 + }` + + var resp SearchResponse + if err := json.Unmarshal([]byte(jsonData), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if resp.TotalResults != 42 { + t.Errorf("expected totalResults=42, got %d", resp.TotalResults) + } + if resp.Limit != 20 { + t.Errorf("expected limit=20, got %d", resp.Limit) + } + if resp.Offset != 0 { + t.Errorf("expected offset=0, got %d", resp.Offset) + } + if len(resp.Data) != 1 { + t.Fatalf("expected 1 result, got %d", len(resp.Data)) + } + + item := resp.Data[0] + if item.UUID != "a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f" { + t.Errorf("unexpected uuid: %s", item.UUID) + } + if item.Name != "CustomerService" { + t.Errorf("unexpected name: %s", item.Name) + } + if item.ServiceType != "OData" { + t.Errorf("unexpected serviceType: %s", item.ServiceType) + } + if item.Environment.Type != "Production" { + t.Errorf("unexpected environment type: %s", item.Environment.Type) + } + if item.Application.Name != "CRM App" { + t.Errorf("unexpected application name: %s", item.Application.Name) + } +} + +func TestSearchResponse_EmptyResults(t *testing.T) { + jsonData := `{ + "data": [], + "totalResults": 0, + "limit": 20, + "offset": 0 + }` + + var resp SearchResponse + if err := json.Unmarshal([]byte(jsonData), &resp); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if len(resp.Data) != 0 { + t.Errorf("expected empty data array, got %d items", len(resp.Data)) + } + if resp.TotalResults != 0 { + t.Errorf("expected totalResults=0, got %d", resp.TotalResults) + } +} diff --git a/mdl/ast/ast_entity.go b/mdl/ast/ast_entity.go index 9e76ce0c..1246d611 100644 --- a/mdl/ast/ast_entity.go +++ b/mdl/ast/ast_entity.go @@ -33,15 +33,15 @@ func (k EntityKind) String() string { // CreateEntityStmt represents: CREATE [OR MODIFY] PERSISTENT|NON-PERSISTENT ENTITY Module.Name [EXTENDS Parent] (attributes) ... type CreateEntityStmt struct { - Name QualifiedName - Kind EntityKind - Generalization *QualifiedName // Parent entity for inheritance (e.g., System.Image) - Attributes []Attribute - Indexes []Index - EventHandlers []EventHandlerDef // ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK CALL ... - Position *Position - Documentation string - Comment string + Name QualifiedName + Kind EntityKind + Generalization *QualifiedName // Parent entity for inheritance (e.g., System.Image) + Attributes []Attribute + Indexes []Index + EventHandlers []EventHandlerDef // ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK CALL ... + Position *Position + Documentation string + Comment string CreateOrModify bool // true for CREATE OR MODIFY } @@ -58,17 +58,17 @@ func (s *DropEntityStmt) isStatement() {} type AlterEntityOp int const ( - AlterEntityAddAttribute AlterEntityOp = iota // ADD ATTRIBUTE / ADD COLUMN - AlterEntityRenameAttribute // RENAME ATTRIBUTE / RENAME COLUMN - AlterEntityModifyAttribute // MODIFY ATTRIBUTE / MODIFY COLUMN - AlterEntityDropAttribute // DROP ATTRIBUTE / DROP COLUMN - AlterEntitySetDocumentation // SET DOCUMENTATION - AlterEntitySetComment // SET COMMENT - AlterEntityAddIndex // ADD INDEX - AlterEntityDropIndex // DROP INDEX - AlterEntitySetPosition // SET POSITION (x, y) - AlterEntityAddEventHandler // ADD EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK CALL Mod.MF - AlterEntityDropEventHandler // DROP EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK + AlterEntityAddAttribute AlterEntityOp = iota // ADD ATTRIBUTE / ADD COLUMN + AlterEntityRenameAttribute // RENAME ATTRIBUTE / RENAME COLUMN + AlterEntityModifyAttribute // MODIFY ATTRIBUTE / MODIFY COLUMN + AlterEntityDropAttribute // DROP ATTRIBUTE / DROP COLUMN + AlterEntitySetDocumentation // SET DOCUMENTATION + AlterEntitySetComment // SET COMMENT + AlterEntityAddIndex // ADD INDEX + AlterEntityDropIndex // DROP INDEX + AlterEntitySetPosition // SET POSITION (x, y) + AlterEntityAddEventHandler // ADD EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK CALL Mod.MF + AlterEntityDropEventHandler // DROP EVENT HANDLER ON BEFORE/AFTER CREATE/COMMIT/DELETE/ROLLBACK ) // EventHandlerDef represents an event handler in CREATE/ALTER ENTITY syntax. @@ -84,17 +84,17 @@ type EventHandlerDef struct { type AlterEntityStmt struct { Name QualifiedName Operation AlterEntityOp - Attribute *Attribute // For ADD ATTRIBUTE - AttributeName string // For RENAME/MODIFY/DROP ATTRIBUTE - NewName string // For RENAME ATTRIBUTE - DataType DataType // For MODIFY ATTRIBUTE - Calculated bool // For MODIFY ATTRIBUTE with CALCULATED - CalculatedMicroflow *QualifiedName // For MODIFY ATTRIBUTE with CALCULATED microflow - Documentation string // For SET DOCUMENTATION - Comment string // For SET COMMENT - Index *Index // For ADD INDEX - IndexName string // For DROP INDEX - Position *Position // For SET POSITION + Attribute *Attribute // For ADD ATTRIBUTE + AttributeName string // For RENAME/MODIFY/DROP ATTRIBUTE + NewName string // For RENAME ATTRIBUTE + DataType DataType // For MODIFY ATTRIBUTE + Calculated bool // For MODIFY ATTRIBUTE with CALCULATED + CalculatedMicroflow *QualifiedName // For MODIFY ATTRIBUTE with CALCULATED microflow + Documentation string // For SET DOCUMENTATION + Comment string // For SET COMMENT + Index *Index // For ADD INDEX + IndexName string // For DROP INDEX + Position *Position // For SET POSITION EventHandler *EventHandlerDef // For ADD/DROP EVENT HANDLER } diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 4753f1cd..76dddc18 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -657,11 +657,11 @@ func (s *ExportToMappingStmt) isMicroflowStatement() {} // TransformJsonStmt represents: $Result = TRANSFORM $Input WITH Module.Transformer type TransformJsonStmt struct { - OutputVariable string // Result string variable (without $) - InputVariable string // Source JSON string variable (without $) - Transformation QualifiedName // Data transformer qualified name - ErrorHandling *ErrorHandlingClause // Optional ON ERROR clause - Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation + OutputVariable string // Result string variable (without $) + InputVariable string // Source JSON string variable (without $) + Transformation QualifiedName // Data transformer qualified name + ErrorHandling *ErrorHandlingClause // Optional ON ERROR clause + Annotations *ActivityAnnotations // Optional @position, @caption, @color, @annotation } func (s *TransformJsonStmt) isMicroflowStatement() {} diff --git a/mdl/ast/ast_query.go b/mdl/ast/ast_query.go index 4fec48c0..5c8ae402 100644 --- a/mdl/ast/ast_query.go +++ b/mdl/ast/ast_query.go @@ -79,7 +79,7 @@ const ( ShowImageCollections // SHOW IMAGE COLLECTIONS [IN module] ShowRestClients // SHOW REST CLIENTS [IN module] ShowPublishedRestServices // SHOW PUBLISHED REST SERVICES [IN module] - ShowDataTransformers // LIST DATA TRANSFORMERS [IN module] + ShowDataTransformers // LIST DATA TRANSFORMERS [IN module] ShowConstantValues // SHOW CONSTANT VALUES [IN module] ShowContractEntities // SHOW CONTRACT ENTITIES FROM Module.Service ShowContractActions // SHOW CONTRACT ACTIONS FROM Module.Service diff --git a/mdl/ast/ast_rest.go b/mdl/ast/ast_rest.go index 7a6992ed..03593831 100644 --- a/mdl/ast/ast_rest.go +++ b/mdl/ast/ast_rest.go @@ -32,15 +32,15 @@ type RestOperationDef struct { Documentation string Method string // "GET", "POST", "PUT", "PATCH", "DELETE" Path string - Parameters []RestParamDef // path parameters - QueryParameters []RestParamDef // query parameters - Headers []RestHeaderDef // HTTP headers - BodyType string // "JSON", "FILE", "TEMPLATE", "MAPPING", "" (none) - BodyVariable string // e.g. "$ItemData" or template string - BodyMapping *RestMappingDef // for MAPPING body - ResponseType string // "JSON", "STRING", "FILE", "STATUS", "NONE", "MAPPING" - ResponseVariable string // e.g. "$CreatedItem" - ResponseMapping *RestMappingDef // for MAPPING response + Parameters []RestParamDef // path parameters + QueryParameters []RestParamDef // query parameters + Headers []RestHeaderDef // HTTP headers + BodyType string // "JSON", "FILE", "TEMPLATE", "MAPPING", "" (none) + BodyVariable string // e.g. "$ItemData" or template string + BodyMapping *RestMappingDef // for MAPPING body + ResponseType string // "JSON", "STRING", "FILE", "STATUS", "NONE", "MAPPING" + ResponseVariable string // e.g. "$CreatedItem" + ResponseMapping *RestMappingDef // for MAPPING response Timeout int } diff --git a/mdl/executor/cmd_rest_clients.go b/mdl/executor/cmd_rest_clients.go index ca44d48e..e879eea9 100644 --- a/mdl/executor/cmd_rest_clients.go +++ b/mdl/executor/cmd_rest_clients.go @@ -441,10 +441,10 @@ func convertMappingEntries(entries []ast.RestMappingEntry, importDirection bool) // Value mapping — direction determines which side is attribute vs JSON field m := &model.RestResponseMapping{} if importDirection { - m.Attribute = e.Left // EntityAttr = jsonField + m.Attribute = e.Left // EntityAttr = jsonField m.ExposedName = e.Right } else { - m.Attribute = e.Right // jsonField = EntityAttr + m.Attribute = e.Right // jsonField = EntityAttr m.ExposedName = e.Left } result = append(result, m) diff --git a/mdl/executor/cmd_security.go b/mdl/executor/cmd_security.go index 1b2958d5..40d51f43 100644 --- a/mdl/executor/cmd_security.go +++ b/mdl/executor/cmd_security.go @@ -629,7 +629,6 @@ func (e *Executor) showSecurityMatrixJSON(moduleName string) error { }) } - return e.writeResult(tr) } diff --git a/model/types.go b/model/types.go index 733db1af..63ca2a14 100644 --- a/model/types.go +++ b/model/types.go @@ -573,11 +573,11 @@ type ServiceOperation struct { // PublishedRestService represents a Rest$PublishedRestService document. type PublishedRestService struct { BaseElement - ContainerID ID `json:"containerId"` - Name string `json:"name"` - Path string `json:"path,omitempty"` - Version string `json:"version,omitempty"` - ServiceName string `json:"serviceName,omitempty"` + ContainerID ID `json:"containerId"` + Name string `json:"name"` + Path string `json:"path,omitempty"` + Version string `json:"version,omitempty"` + ServiceName string `json:"serviceName,omitempty"` Excluded bool `json:"excluded,omitempty"` AllowedRoles []string `json:"allowedRoles,omitempty"` Resources []*PublishedRestResource `json:"resources,omitempty"` @@ -658,9 +658,9 @@ type RestClientOperation struct { BodyMappings []*RestResponseMapping `json:"bodyMappings,omitempty"` // export mapping tree (Entity → JSON) for EXPORT_MAPPING bodies ResponseType string `json:"responseType"` // "JSON", "STRING", "FILE", "STATUS", "NONE", "MAPPING" ResponseVariable string `json:"responseVariable,omitempty"` - ResponseEntity string `json:"responseEntity,omitempty"` // target entity for implicit mapping response - ResponseMappings []*RestResponseMapping `json:"responseMappings,omitempty"` // JSON field → entity attribute - Timeout int `json:"timeout,omitempty"` // 0 = default (300s) + ResponseEntity string `json:"responseEntity,omitempty"` // target entity for implicit mapping response + ResponseMappings []*RestResponseMapping `json:"responseMappings,omitempty"` // JSON field → entity attribute + Timeout int `json:"timeout,omitempty"` // 0 = default (300s) } // RestResponseMapping represents one element in a response mapping tree. @@ -668,9 +668,9 @@ type RestClientOperation struct { // (Entity set, with its own Children). type RestResponseMapping struct { // Value mapping: maps a JSON field to an entity attribute - Attribute string `json:"attribute,omitempty"` // entity attribute short name - ExposedName string `json:"exposedName"` // JSON field name - JsonPath string `json:"jsonPath,omitempty"` // e.g. "(Object)|args|queryparam_1" + Attribute string `json:"attribute,omitempty"` // entity attribute short name + ExposedName string `json:"exposedName"` // JSON field name + JsonPath string `json:"jsonPath,omitempty"` // e.g. "(Object)|args|queryparam_1" // Object mapping: nested entity linked by association Entity string `json:"entity,omitempty"` // child entity (e.g. "RestDemo.Args") diff --git a/sdk/agenteditor/types.go b/sdk/agenteditor/types.go index 35250918..4e9dbfe2 100644 --- a/sdk/agenteditor/types.go +++ b/sdk/agenteditor/types.go @@ -81,7 +81,7 @@ type Model struct { // Portal-populated fields — usually empty on freshly-created documents. // They are filled by Studio Pro after the user clicks "Test Key". Type string `json:"type,omitempty"` - InnerName string `json:"innerName,omitempty"` // Contents.name field + InnerName string `json:"innerName,omitempty"` // Contents.name field DisplayName string `json:"displayName,omitempty"` // User-set: provider discriminator. Only observed value: "MxCloudGenAI". @@ -165,19 +165,19 @@ type Agent struct { Excluded bool `json:"excluded,omitempty"` ExportLevel string `json:"exportLevel,omitempty"` - Description string `json:"description,omitempty"` - SystemPrompt string `json:"systemPrompt,omitempty"` - UserPrompt string `json:"userPrompt,omitempty"` - UsageType string `json:"usageType,omitempty"` - Variables []AgentVar `json:"variables,omitempty"` - Tools []AgentTool `json:"tools,omitempty"` - KBTools []AgentKBTool `json:"knowledgebaseTools,omitempty"` - Model *DocRef `json:"model,omitempty"` - Entity *DocRef `json:"entity,omitempty"` - MaxTokens *int `json:"maxTokens,omitempty"` - ToolChoice string `json:"toolChoice,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"topP,omitempty"` + Description string `json:"description,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty"` + UserPrompt string `json:"userPrompt,omitempty"` + UsageType string `json:"usageType,omitempty"` + Variables []AgentVar `json:"variables,omitempty"` + Tools []AgentTool `json:"tools,omitempty"` + KBTools []AgentKBTool `json:"knowledgebaseTools,omitempty"` + Model *DocRef `json:"model,omitempty"` + Entity *DocRef `json:"entity,omitempty"` + MaxTokens *int `json:"maxTokens,omitempty"` + ToolChoice string `json:"toolChoice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` } // GetName returns the agent's name. diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index 374d1216..5b85d094 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -595,11 +595,11 @@ func (RestCallAction) isMicroflowAction() {} // BSON type: Microflows$RestOperationCallAction type RestOperationCallAction struct { model.BaseElement - ErrorHandlingType ErrorHandlingType `json:"errorHandlingType,omitempty"` - Operation string `json:"operation,omitempty"` // BY_NAME: Module.Service.Operation - OutputVariable *RestOutputVar `json:"outputVariable,omitempty"` // null or Microflows$OutputVariable - BodyVariable *RestBodyVar `json:"bodyVariable,omitempty"` // null or nested object - ParameterMappings []*RestParameterMapping `json:"parameterMappings,omitempty"` // path parameter bindings + ErrorHandlingType ErrorHandlingType `json:"errorHandlingType,omitempty"` + Operation string `json:"operation,omitempty"` // BY_NAME: Module.Service.Operation + OutputVariable *RestOutputVar `json:"outputVariable,omitempty"` // null or Microflows$OutputVariable + BodyVariable *RestBodyVar `json:"bodyVariable,omitempty"` // null or nested object + ParameterMappings []*RestParameterMapping `json:"parameterMappings,omitempty"` // path parameter bindings QueryParameterMappings []*RestQueryParameterMapping `json:"queryParameterMappings,omitempty"` // query parameter bindings } diff --git a/sdk/mpr/parser_customblob.go b/sdk/mpr/parser_customblob.go index b909bb3d..30b46e23 100644 --- a/sdk/mpr/parser_customblob.go +++ b/sdk/mpr/parser_customblob.go @@ -99,12 +99,12 @@ func (r *Reader) parseAgentEditorModel(unitID, containerID string, contents []by DisplayName string `json:"displayName"` Provider string `json:"provider"` ProviderFields struct { - Environment string `json:"environment"` - DeepLinkURL string `json:"deepLinkURL"` - KeyID string `json:"keyId"` - KeyName string `json:"keyName"` - ResourceName string `json:"resourceName"` - Key *agenteditor.ConstantRef `json:"key"` + Environment string `json:"environment"` + DeepLinkURL string `json:"deepLinkURL"` + KeyID string `json:"keyId"` + KeyName string `json:"keyName"` + ResourceName string `json:"resourceName"` + Key *agenteditor.ConstantRef `json:"key"` } `json:"providerFields"` } if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { @@ -259,19 +259,19 @@ func (r *Reader) parseAgentEditorAgent(unitID, containerID string, contents []by // Decode the fields we know about; unknown fields are ignored so // the parser stays forward-compatible with editor updates. var payload struct { - Description string `json:"description"` - SystemPrompt string `json:"systemPrompt"` - UserPrompt string `json:"userPrompt"` - UsageType string `json:"usageType"` - Variables []agenteditor.AgentVar `json:"variables"` - Tools []agenteditor.AgentTool `json:"tools"` + Description string `json:"description"` + SystemPrompt string `json:"systemPrompt"` + UserPrompt string `json:"userPrompt"` + UsageType string `json:"usageType"` + Variables []agenteditor.AgentVar `json:"variables"` + Tools []agenteditor.AgentTool `json:"tools"` KnowledgebaseTools []agenteditor.AgentKBTool `json:"knowledgebaseTools"` - Model *agenteditor.DocRef `json:"model"` - Entity *agenteditor.DocRef `json:"entity"` - MaxTokens *int `json:"maxTokens"` - ToolChoice string `json:"toolChoice"` - Temperature *float64 `json:"temperature"` - TopP *float64 `json:"topP"` + Model *agenteditor.DocRef `json:"model"` + Entity *agenteditor.DocRef `json:"entity"` + MaxTokens *int `json:"maxTokens"` + ToolChoice string `json:"toolChoice"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"topP"` } if err := json.Unmarshal([]byte(wrap.Contents), &payload); err != nil { return nil, fmt.Errorf("failed to unmarshal agent-editor Agent Contents JSON: %w", err) diff --git a/sdk/mpr/writer_datatransformer.go b/sdk/mpr/writer_datatransformer.go index c7bf3c2e..90f8dab8 100644 --- a/sdk/mpr/writer_datatransformer.go +++ b/sdk/mpr/writer_datatransformer.go @@ -84,8 +84,8 @@ func serializeDataTransformer(dt *model.DataTransformer) ([]byte, error) { steps = append(steps, bson.M{ "$ID": idToBsonBinary(generateUUID()), - "$Type": "DataTransformers$Step", - "Action": action, + "$Type": "DataTransformers$Step", + "Action": action, "InputElementPointer": idToBsonBinary(rootElemID), "OutputElementPointer": idToBsonBinary(rootElemID), }) diff --git a/sdk/mpr/writer_rest.go b/sdk/mpr/writer_rest.go index 627e3478..970852f9 100644 --- a/sdk/mpr/writer_rest.go +++ b/sdk/mpr/writer_rest.go @@ -454,19 +454,19 @@ func (w *Writer) serializePublishedRestService(svc *model.PublishedRestService) ops := bson.A{int32(2)} for _, op := range res.Operations { opDoc := bson.M{ - "$ID": idToBsonBinary(GenerateID()), - "$Type": "Rest$PublishedRestServiceOperation", - "HttpMethod": httpMethodToMendix(op.HTTPMethod), - "Path": op.Path, - "Microflow": op.Microflow, - "Summary": op.Summary, - "Deprecated": op.Deprecated, - "Commit": "Yes", - "Documentation": "", - "ExportMapping": "", - "ImportMapping": "", + "$ID": idToBsonBinary(GenerateID()), + "$Type": "Rest$PublishedRestServiceOperation", + "HttpMethod": httpMethodToMendix(op.HTTPMethod), + "Path": op.Path, + "Microflow": op.Microflow, + "Summary": op.Summary, + "Deprecated": op.Deprecated, + "Commit": "Yes", + "Documentation": "", + "ExportMapping": "", + "ImportMapping": "", "ObjectHandlingBackup": "Create", - "Parameters": serializePublishedRestParams(op.Path, op.Microflow, op.Parameters), + "Parameters": serializePublishedRestParams(op.Path, op.Microflow, op.Parameters), } ops = append(ops, opDoc) } @@ -481,21 +481,21 @@ func (w *Writer) serializePublishedRestService(svc *model.PublishedRestService) } doc := bson.M{ - "$ID": idToBsonBinary(string(svc.ID)), - "$Type": "Rest$PublishedRestService", - "Name": svc.Name, - "Documentation": "", - "Excluded": svc.Excluded, - "ExportLevel": "Hidden", - "Path": svc.Path, - "Version": svc.Version, - "ServiceName": svc.ServiceName, - "AllowedRoles": makeMendixStringArray(svc.AllowedRoles), - "AuthenticationTypes": bson.A{int32(2)}, + "$ID": idToBsonBinary(string(svc.ID)), + "$Type": "Rest$PublishedRestService", + "Name": svc.Name, + "Documentation": "", + "Excluded": svc.Excluded, + "ExportLevel": "Hidden", + "Path": svc.Path, + "Version": svc.Version, + "ServiceName": svc.ServiceName, + "AllowedRoles": makeMendixStringArray(svc.AllowedRoles), + "AuthenticationTypes": bson.A{int32(2)}, "AuthenticationMicroflow": "", - "CorsConfiguration": nil, - "Parameters": bson.A{int32(2)}, - "Resources": resources, + "CorsConfiguration": nil, + "Parameters": bson.A{int32(2)}, + "Resources": resources, } return bson.Marshal(doc) diff --git a/sdk/pages/pages_datasources.go b/sdk/pages/pages_datasources.go index 594903d0..ee02ce9e 100644 --- a/sdk/pages/pages_datasources.go +++ b/sdk/pages/pages_datasources.go @@ -78,7 +78,7 @@ func (ListenToWidgetSource) isDataSource() {} // AssociationSource retrieves data via association. type AssociationSource struct { model.BaseElement - EntityPath string `json:"entityPath"` // "Module.Assoc" or "Module.Assoc/Module.DestEntity" + EntityPath string `json:"entityPath"` // "Module.Assoc" or "Module.Assoc/Module.DestEntity" ContextVariable string `json:"contextVariable,omitempty"` // page parameter name (without $) — empty for $currentObject } From 2fa140941c57069a759f1199a89545fedf9c47bf Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:38:44 +0200 Subject: [PATCH 04/10] fix: correct businessOwner/technicalOwner type in Application API returns owner objects {name, email, uuid}, not strings. Discovered during manual testing against real Catalog API. Co-Authored-By: Claude Sonnet 4.5 --- internal/catalog/types.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/catalog/types.go b/internal/catalog/types.go index b0391aa0..dd5eb6c2 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -47,6 +47,13 @@ type Application struct { Name string `json:"name"` Description string `json:"description"` UUID string `json:"uuid"` - BusinessOwner string `json:"businessOwner"` - TechnicalOwner string `json:"technicalOwner"` + BusinessOwner *Owner `json:"businessOwner,omitempty"` + TechnicalOwner *Owner `json:"technicalOwner,omitempty"` +} + +// Owner represents a business or technical owner. +type Owner struct { + Name string `json:"name"` + Email string `json:"email"` + UUID string `json:"uuid"` } From 512a4750284a1549d0bef3d5080e85fc853e450a Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:39:11 +0200 Subject: [PATCH 05/10] test: update test data to match corrected Owner type Owner is an object, not a string. Co-Authored-By: Claude Sonnet 4.5 --- internal/catalog/types_test.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/catalog/types_test.go b/internal/catalog/types_test.go index 21f3bbae..efdbacde 100644 --- a/internal/catalog/types_test.go +++ b/internal/catalog/types_test.go @@ -26,8 +26,16 @@ func TestSearchResponse_UnmarshalJSON(t *testing.T) { "name": "CRM App", "description": "CRM system", "uuid": "app-uuid", - "businessOwner": "owner@example.com", - "technicalOwner": "tech@example.com" + "businessOwner": { + "name": "Business Owner", + "email": "owner@example.com", + "uuid": "owner-uuid" + }, + "technicalOwner": { + "name": "Technical Owner", + "email": "tech@example.com", + "uuid": "tech-uuid" + } }, "securityClassification": "Internal", "lastUpdated": "2026-04-16T10:00:00Z", From 46c07bf218aaa1a5e088b383c7946b5b51f2e97e Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:45:52 +0200 Subject: [PATCH 06/10] docs: update proposal to include catalog show in Phase 1 Phase 1 now includes both search and show commands. Client creation remains in Phase 2 (architecture TBD). Co-Authored-By: Claude Sonnet 4.5 --- .../PROPOSAL_catalog_integration.md | 95 +++++++++++++------ 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/docs/11-proposals/PROPOSAL_catalog_integration.md b/docs/11-proposals/PROPOSAL_catalog_integration.md index 04307e76..3956d768 100644 --- a/docs/11-proposals/PROPOSAL_catalog_integration.md +++ b/docs/11-proposals/PROPOSAL_catalog_integration.md @@ -26,7 +26,7 @@ Once discovery is implemented, a natural follow-up is **automatic OData client g mxcli catalog create-odata-client --into MyModule ``` -This would fetch metadata from the Catalog-registered endpoint and execute `CREATE EXTERNAL ENTITIES` automatically. However, this proposal focuses solely on **read-only search and discovery** to unblock manual workflows first. +This would fetch metadata from the Catalog-registered endpoint and execute `CREATE EXTERNAL ENTITIES` automatically. However, this proposal focuses on **Phase 1: search and endpoint inspection** to unblock manual workflows first. Client generation is deferred to Phase 2 pending architecture discussion. ## API Discovery @@ -241,6 +241,66 @@ mxcli catalog search "api" --json \ | jq -r '.[] | "- [\(.name)](\(.application.name)) - \(.description)"' ``` +### `mxcli catalog show [flags]` + +**Synopsis:** +```bash +mxcli catalog show [flags] +``` + +**Arguments:** +- `` — Required endpoint UUID (from search results) + +**Flags:** +- `--profile ` — Auth profile (default: "default") +- `--json` — Output full JSON response including embedded contract + +**Examples:** + +```bash +# Show endpoint details (human-readable) +mxcli catalog show a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f + +# JSON output with full contract +mxcli catalog show a7f3c2d1 --json | jq '.serviceVersion.contracts[0].documents[0].contents' +``` + +**Human-Readable Output:** + +``` +Name: CustomerService +Type: OData +Version: 1.2.0 +Application: CRM Application +Environment: Production (EU) +Location: https://crm.acme.com/odata/customer/v1 +Description: Manages customer data and relationships + +Security: Basic, MxID +Validated: Yes +Last Updated: 2026-04-10T14:32:00Z + +Entities (3): + - Customer (6 attributes, 2 associations) + Attributes: Name, Email, Phone, Address, City, PostalCode + Associations: Customer_Order, Customer_Address + - Order (5 attributes, 1 association) + - Address (4 attributes) + +Actions (2): + - CalculateDiscount (parameters: CustomerId, DiscountCode) + - ValidateCustomer (parameters: Email) +``` + +**JSON Output:** + +Returns the complete `/endpoints/{uuid}` API response, including: +- Full endpoint metadata +- Embedded contract (`serviceVersion.contracts[0].documents[0].contents`) +- Entity and action details +- Security scheme +- Application and environment metadata + ## Implementation Plan ### 1. File Structure @@ -707,34 +767,7 @@ Choose based on user feedback and implementation complexity. ## Future Enhancements (Out of Scope) -### 1. `mxcli catalog show ` - -Show detailed endpoint metadata: - -```bash -mxcli catalog show a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f - -# Output: -Name: CustomerService -Type: OData -Version: 1.2.0 -Application: CRM Application -Environment: Production (EU) -Description: Manages customer data and relationships - -Entities (3): - - Customer (attributes: Name, Email, Phone) - - Order (attributes: OrderNumber, Date, TotalAmount) - - Address (attributes: Street, City, PostalCode) - -Actions (2): - - CalculateDiscount - - ValidateCustomer -``` - -**Implementation:** Call `GET /endpoints/{uuid}`, parse entities/actions, format as hierarchical output. - -### 2. `mxcli catalog create-odata-client ` +### 1. `mxcli catalog create-odata-client ` Generate OData client from Catalog entry: @@ -746,9 +779,9 @@ mxcli catalog create-odata-client a7f3c2d1 --into MyModule -p app.mpr # 2. CREATE EXTERNAL ENTITIES FROM 'http://...$metadata' INTO MyModule ``` -**Implementation:** Fetch endpoint details, extract metadata URL, call existing `CREATE EXTERNAL ENTITIES` executor. +**Implementation:** Fetch endpoint details, extract metadata content, call existing `CREATE EXTERNAL ENTITIES` executor with embedded metadata. -### 3. Interactive Search UI +### 2. Interactive Search UI TUI with arrow-key navigation and fuzzy search: From a03b59059168dc475f5ba2b0c72ebe43efa7bcc4 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 14:51:06 +0200 Subject: [PATCH 07/10] feat: add catalog show command and use full UUIDs in search Implements 'mxcli catalog show ' for detailed endpoint inspection and changes search output to show full UUIDs instead of shortened. Changes: - Show full UUID (36 chars) in search table output - Users can copy UUID directly for use with show command - Add catalog show command with human-readable and JSON output - Display entities, actions, security scheme, environment New types: - EndpointDetails, ServiceVersion, Contract, Document - Entity, Attribute, Association - Action, Parameter, ReturnType - SecurityScheme, SecurityType, Role - EnvironmentWithApp (nested Application in endpoint response) New client method: - GetEndpoint(ctx, uuid) - Calls GET /endpoints/{uuid} Updated documentation: - Skills file reflects full UUID in table - Proposal updated with full UUID rationale - Table width now ~155 chars with full UUIDs Tested against real Catalog API. Co-Authored-By: Claude Sonnet 4.5 --- .claude/skills/mendix/catalog-search.md | 10 +- cmd/mxcli/cmd_catalog.go | 125 +++++++++++++++- .../PROPOSAL_catalog_integration.md | 14 +- internal/catalog/client.go | 32 ++++ internal/catalog/types.go | 138 ++++++++++++++++++ 5 files changed, 303 insertions(+), 16 deletions(-) diff --git a/.claude/skills/mendix/catalog-search.md b/.claude/skills/mendix/catalog-search.md index ca4531f0..17c4b97c 100644 --- a/.claude/skills/mendix/catalog-search.md +++ b/.claude/skills/mendix/catalog-search.md @@ -37,10 +37,10 @@ mxcli catalog search "data" --json | jq '.[] | {name, uuid, type}' **Table (default):** ``` -NAME TYPE VERSION APPLICATION ENVIRONMENT PROD UUID -CustomerService OData 1.2.0 CRM Application Production Yes a7f3c2d1 -OrderAPI REST 2.0.1 E-commerce Platform Acceptance No b8e4d3e2 -InventorySync SOAP 1.0.0 Warehouse System Test No c9f5e4f3 +NAME TYPE VERSION APPLICATION ENVIRONMENT PROD UUID +CustomerService OData 1.2.0 CRM Application Production Yes a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f +OrderAPI REST 2.0.1 E-commerce Platform Acceptance No b8e4d3e2-1a2b-3c4d-5e6f-7a8b9c0d1e2f +InventorySync SOAP 1.0.0 Warehouse System Test No c9f5e4f3-2b3c-4d5e-6f7a-8b9c0d1e2f3a Total: 42 results (showing 1-3) ``` @@ -51,7 +51,7 @@ Total: 42 results (showing 1-3) - **APPLICATION**: Hosting application name - **ENVIRONMENT**: Production, Acceptance, Test - **PROD**: "Yes" if production, blank otherwise -- **UUID**: First 8 characters (full UUID in JSON mode) +- **UUID**: Full UUID (36 chars) - copy this for use with `mxcli catalog show ` **JSON mode:** ```bash diff --git a/cmd/mxcli/cmd_catalog.go b/cmd/mxcli/cmd_catalog.go index e65773e1..6a170192 100644 --- a/cmd/mxcli/cmd_catalog.go +++ b/cmd/mxcli/cmd_catalog.go @@ -5,6 +5,7 @@ package main import ( "encoding/json" "fmt" + "strings" "text/tabwriter" "github.com/mendixlabs/mxcli/internal/auth" @@ -38,6 +39,18 @@ Examples: RunE: runCatalogSearch, } +var catalogShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show detailed endpoint metadata", + Long: `Display detailed metadata for a Catalog endpoint including entities, actions, and contract. + +Examples: + mxcli catalog show a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f + mxcli catalog show a7f3c2d1 --json`, + Args: cobra.ExactArgs(1), + RunE: runCatalogShow, +} + func init() { catalogSearchCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name") catalogSearchCmd.Flags().String("service-type", "", "filter by service type (OData, REST, SOAP)") @@ -47,7 +60,11 @@ func init() { catalogSearchCmd.Flags().Int("offset", 0, "pagination offset") catalogSearchCmd.Flags().Bool("json", false, "output as JSON array") + catalogShowCmd.Flags().String("profile", auth.ProfileDefault, "credential profile name") + catalogShowCmd.Flags().Bool("json", false, "output full JSON response") + catalogCmd.AddCommand(catalogSearchCmd) + catalogCmd.AddCommand(catalogShowCmd) rootCmd.AddCommand(catalogCmd) } @@ -113,10 +130,7 @@ func outputTable(cmd *cobra.Command, resp *catalog.SearchResponse) error { if item.Environment.Type == "Production" { prod = "Yes" } - uuid := item.UUID - if len(uuid) >= 8 { - uuid = uuid[:8] // Short UUID - } + uuid := item.UUID // Full UUID (36 chars) so users can use it with `show` fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", name, typ, version, app, env, prod, uuid) @@ -134,6 +148,109 @@ func outputJSON(cmd *cobra.Command, data []catalog.SearchResult) error { return enc.Encode(data) } +func runCatalogShow(cmd *cobra.Command, args []string) error { + uuid := args[0] + profile, _ := cmd.Flags().GetString("profile") + asJSON, _ := cmd.Flags().GetBool("json") + + // Create client + client, err := catalog.NewClient(cmd.Context(), profile) + if err != nil { + if _, ok := err.(*auth.ErrNoCredential); ok { + return fmt.Errorf("no credential found. Run: mxcli auth login") + } + return err + } + + // Get endpoint details + endpoint, err := client.GetEndpoint(cmd.Context(), uuid) + if err != nil { + if _, ok := err.(*auth.ErrUnauthenticated); ok { + return fmt.Errorf("authentication failed. Run: mxcli auth login") + } + return err + } + + // Output + if asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(endpoint) + } + return outputEndpointDetails(cmd, endpoint) +} + +func outputEndpointDetails(cmd *cobra.Command, ep *catalog.EndpointDetails) error { + w := cmd.OutOrStdout() + sv := ep.ServiceVersion + + // Basic info + fmt.Fprintf(w, "Name: %s\n", sv.Description) + fmt.Fprintf(w, "Type: %s\n", sv.Type) + fmt.Fprintf(w, "Version: %s\n", sv.Version) + fmt.Fprintf(w, "Application: %s\n", ep.Environment.Application.Name) + fmt.Fprintf(w, "Environment: %s (%s)\n", ep.Environment.Type, ep.Environment.Location) + if ep.Location != "" { + fmt.Fprintf(w, "Location: %s\n", ep.Location) + } + fmt.Fprintf(w, "\n") + + // Security + if sv.SecurityScheme != nil && len(sv.SecurityScheme.SecurityTypes) > 0 { + var types []string + for _, st := range sv.SecurityScheme.SecurityTypes { + types = append(types, st.Name) + } + fmt.Fprintf(w, "Security: %s\n", strings.Join(types, ", ")) + } + fmt.Fprintf(w, "Validated: %v\n", ep.Validated) + fmt.Fprintf(w, "Last Updated: %s\n", ep.LastUpdated) + fmt.Fprintf(w, "\n") + + // Entities (OData only) + if sv.TotalEntities > 0 { + fmt.Fprintf(w, "Entities (%d):\n", sv.TotalEntities) + for _, ent := range sv.Entities { + fmt.Fprintf(w, " - %s (%d attributes", ent.Name, ent.TotalAttributes) + if ent.TotalAssociations > 0 { + fmt.Fprintf(w, ", %d associations", ent.TotalAssociations) + } + fmt.Fprintf(w, ")\n") + + // Show first 3 attributes + if len(ent.Attributes) > 0 { + var attrNames []string + for i, attr := range ent.Attributes { + if i >= 3 { + break + } + attrNames = append(attrNames, attr.Name) + } + fmt.Fprintf(w, " Attributes: %s", strings.Join(attrNames, ", ")) + if len(ent.Attributes) > 3 { + fmt.Fprintf(w, ", ...") + } + fmt.Fprintf(w, "\n") + } + } + fmt.Fprintf(w, "\n") + } + + // Actions (OData only) + if sv.TotalActions > 0 { + fmt.Fprintf(w, "Actions (%d):\n", sv.TotalActions) + for _, action := range sv.Actions { + fmt.Fprintf(w, " - %s", action.Name) + if action.TotalParameters > 0 { + fmt.Fprintf(w, " (%d parameters)", action.TotalParameters) + } + fmt.Fprintf(w, "\n") + } + } + + return nil +} + func truncate(s string, max int) string { if len(s) <= max { return s diff --git a/docs/11-proposals/PROPOSAL_catalog_integration.md b/docs/11-proposals/PROPOSAL_catalog_integration.md index 3956d768..acd19220 100644 --- a/docs/11-proposals/PROPOSAL_catalog_integration.md +++ b/docs/11-proposals/PROPOSAL_catalog_integration.md @@ -186,13 +186,13 @@ mxcli catalog search "sales" --owned-only ### Table Output Format -**Design Decision:** 7 columns, ~120 chars wide (fits standard terminal width). +**Design Decision:** 7 columns, ~155 chars wide with full UUIDs. ``` -NAME TYPE VERSION APPLICATION ENVIRONMENT PROD UUID -CustomerService OData 1.2.0 CRM Application Production Yes a7f3c2d1 -OrderAPI REST 2.0.1 E-commerce Platform Acceptance No b8e4d3e2 -InventorySync SOAP 1.0.0 Warehouse System Test No c9f5e4f3 +NAME TYPE VERSION APPLICATION ENVIRONMENT PROD UUID +CustomerService OData 1.2.0 CRM Application Production Yes a7f3c2d1-4b5e-6c7f-8d9e-0a1b2c3d4e5f +OrderAPI REST 2.0.1 E-commerce Platform Acceptance No b8e4d3e2-1a2b-3c4d-5e6f-7a8b9c0d1e2f +InventorySync SOAP 1.0.0 Warehouse System Test No c9f5e4f3-2b3c-4d5e-6f7a-8b9c0d1e2f3a ``` **Column Widths:** @@ -202,10 +202,10 @@ InventorySync SOAP 1.0.0 Warehouse System Test No - APPLICATION (20 chars) — Truncate with "..." if longer - ENVIRONMENT (12 chars) — Type field (Production, Acceptance, Test) - PROD (4 chars) — "Yes" if environment.Type == "Production", blank otherwise -- UUID (8 chars) — First 8 chars only (full UUID available in --json mode) +- UUID (36 chars) — Full UUID for use with `mxcli catalog show ` **Rationale:** -- Short UUIDs reduce cognitive load while remaining unique enough for manual lookup +- Full UUIDs required for `catalog show` command (API requires full UUID) - PROD column provides at-a-glance production status without reading ENVIRONMENT - APPLICATION provides context without requiring a separate lookup - Full details available via `--json` for scripting use cases diff --git a/internal/catalog/client.go b/internal/catalog/client.go index d2e5f377..2d41c9e0 100644 --- a/internal/catalog/client.go +++ b/internal/catalog/client.go @@ -84,3 +84,35 @@ func (c *Client) Search(ctx context.Context, opts SearchOptions) (*SearchRespons return &result, nil } + +// GetEndpoint retrieves detailed endpoint metadata by UUID. +// Calls GET /endpoints/{uuid} and returns parsed result including embedded contract. +func (c *Client) GetEndpoint(ctx context.Context, uuid string) (*EndpointDetails, error) { + reqURL := c.baseURL + "/endpoints/" + uuid + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + // auth.authTransport wraps 401/403 as auth.ErrUnauthenticated + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("endpoint %s not found", uuid) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("catalog API returned status %d", resp.StatusCode) + } + + var result EndpointDetails + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return &result, nil +} diff --git a/internal/catalog/types.go b/internal/catalog/types.go index dd5eb6c2..0da0d395 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -57,3 +57,141 @@ type Owner struct { Email string `json:"email"` UUID string `json:"uuid"` } + +// EndpointDetails represents the full response from GET /endpoints/{uuid}. +type EndpointDetails struct { + UUID string `json:"uuid"` + Path string `json:"path"` + Location string `json:"location"` + Discoverable bool `json:"discoverable"` + Validated bool `json:"validated"` + SecurityClassification string `json:"securityClassification"` + Connections int `json:"connections"` + LastUpdated string `json:"lastUpdated"` + ServiceVersion ServiceVersion `json:"serviceVersion"` + Environment EnvironmentWithApp `json:"environment"` +} + +// EnvironmentWithApp extends Environment with nested Application (used in endpoint details). +type EnvironmentWithApp struct { + Name string `json:"name"` + Location string `json:"location"` + Type string `json:"type"` + UUID string `json:"uuid"` + Application Application `json:"application"` +} + +// ServiceVersion contains version-specific metadata and contract. +type ServiceVersion struct { + Version string `json:"version"` + Description string `json:"description"` + UUID string `json:"uuid"` + PublishDate string `json:"publishDate"` + Type string `json:"type"` // "OData", "REST", "SOAP" + Contracts []Contract `json:"contracts"` + SecurityScheme *SecurityScheme `json:"securityScheme,omitempty"` + TotalEntities int `json:"totalEntities"` + Entities []Entity `json:"entities"` + TotalActions int `json:"totalActions"` + Actions []Action `json:"actions"` +} + +// Contract represents an API contract (metadata, OpenAPI, WSDL, etc). +type Contract struct { + Type string `json:"type"` // "CSDL", "OpenAPI", "WSDL" + SpecificationVersion string `json:"specificationVersion"` + DocumentBaseURL string `json:"documentBaseURL"` + Documents []Document `json:"documents"` +} + +// Document contains the actual contract content. +type Document struct { + IsPrimary bool `json:"isPrimary"` + URI string `json:"uri"` + Contents string `json:"contents"` // Embedded XML/JSON contract +} + +// SecurityScheme describes authentication requirements. +type SecurityScheme struct { + SecurityTypes []SecurityType `json:"securityTypes"` + MxAllowedRoles []Role `json:"mxAllowedRoles,omitempty"` +} + +// SecurityType represents an authentication method. +type SecurityType struct { + Name string `json:"name"` // "Basic", "MxID", etc. + MarketplaceModuleID string `json:"marketplaceModuleID,omitempty"` +} + +// Role represents a Mendix module role. +type Role struct { + Name string `json:"name"` + UUID string `json:"uuid"` +} + +// Entity represents an OData entity set. +type Entity struct { + Name string `json:"name"` + EntitySetName string `json:"entitySetName"` + EntityTypeName string `json:"entityTypeName"` + Namespace string `json:"namespace"` + Validated bool `json:"validated"` + Updatable bool `json:"updatable"` + Insertable bool `json:"insertable"` + Deletable bool `json:"deletable"` + TotalAttributes int `json:"totalAttributes"` + Attributes []Attribute `json:"attributes"` + TotalAssociations int `json:"totalAssociations"` + Associations []Association `json:"associations"` +} + +// Attribute represents an entity attribute. +type Attribute struct { + Name string `json:"name"` + TypeName string `json:"typeName"` + TypeKind string `json:"typeKind"` + Updatable bool `json:"updatable"` + Insertable bool `json:"insertable"` + Filterable bool `json:"filterable"` + Sortable bool `json:"sortable"` +} + +// Association represents an entity association. +type Association struct { + Name string `json:"name"` + ReferencedDataset string `json:"referencedDataset"` + Multiplicity string `json:"multiplicity"` + EntitySetName string `json:"entitySetName"` + EntityTypeName string `json:"entityTypeName"` + Namespace string `json:"namespace"` +} + +// Action represents an OData action or function. +type Action struct { + Name string `json:"name"` + FullyQualifiedName string `json:"fullyQualifiedName"` + Summary string `json:"summary"` + Description string `json:"description"` + TotalParameters int `json:"totalParameters"` + Parameters []Parameter `json:"parameters"` + ReturnType *ReturnType `json:"returnType,omitempty"` +} + +// Parameter represents an action/function parameter. +type Parameter struct { + Name string `json:"name"` + TypeKind string `json:"typekind"` + TypeName string `json:"typeName"` + IsCollection bool `json:"isCollection"` + Nullable bool `json:"nullable"` + Summary string `json:"summary"` + Description string `json:"description"` +} + +// ReturnType represents an action/function return type. +type ReturnType struct { + TypeKind string `json:"typekind"` + TypeName string `json:"typeName"` + IsCollection bool `json:"isCollection"` + Nullable bool `json:"nullable"` +} From 67afe0e5348825c67582b8eaaeeadef5206ae582 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 15:49:20 +0200 Subject: [PATCH 08/10] docs: disambiguate Mendix Catalog (CLI) from MDL CATALOG keyword Add prominent warnings in: - catalog-search.md skill: clarify this is the external service registry - browse-integrations.md skill: clarify this is MDL CATALOG for local queries - cmd_catalog.go: add disambiguation note in CLI help text - PROPOSAL_catalog_integration.md: add terminology note at top This prevents confusion between: 1. Mendix Catalog (catalog.mendix.com) - external service registry, CLI commands 2. MDL CATALOG keyword - local project metadata tables, SQL queries Co-Authored-By: Claude Sonnet 4.5 --- .claude/skills/mendix/browse-integrations.md | 4 ++- .claude/skills/mendix/catalog-search.md | 25 +++++++++++++++++-- cmd/mxcli/cmd_catalog.go | 8 ++++-- .../PROPOSAL_catalog_integration.md | 2 ++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.claude/skills/mendix/browse-integrations.md b/.claude/skills/mendix/browse-integrations.md index dd695a0d..46aa4f12 100644 --- a/.claude/skills/mendix/browse-integrations.md +++ b/.claude/skills/mendix/browse-integrations.md @@ -1,6 +1,8 @@ # Browse Integration Services and Contracts -This skill covers discovering external services, browsing cached contracts, and querying integration assets via the catalog. +This skill covers discovering external services, browsing cached contracts, and querying integration assets via the **MDL CATALOG** (local project metadata). + +**⚠️ NOTE:** This covers the **MDL CATALOG keyword** (`SELECT ... FROM CATALOG.entities`), NOT the **Mendix Catalog CLI** (`mxcli catalog search`). See `.claude/skills/mendix/catalog-search.md` for the external service registry. ## When to Use This Skill diff --git a/.claude/skills/mendix/catalog-search.md b/.claude/skills/mendix/catalog-search.md index 17c4b97c..a1e2936b 100644 --- a/.claude/skills/mendix/catalog-search.md +++ b/.claude/skills/mendix/catalog-search.md @@ -1,6 +1,8 @@ -# Catalog Search +# Catalog Search (Mendix Platform Service Registry) -Search and discover services registered in Mendix Catalog programmatically. +Search and discover services registered in **Mendix Catalog** (catalog.mendix.com) programmatically. + +**⚠️ NOTE:** This is the **external Mendix Catalog service** (CLI: `mxcli catalog search`), NOT the **MDL CATALOG keyword** which queries local project metadata tables (`SELECT ... FROM CATALOG.entities`). See `.claude/skills/mendix/browse-integrations.md` for MDL CATALOG queries. ## Authentication Required @@ -142,7 +144,26 @@ Phase 2 (not yet implemented): See GitHub issue #213 for architecture discussion. +## Disambiguation: Two Different "Catalogs" + +**Mendix Catalog** (this skill): +- **What**: External service registry at catalog.mendix.com +- **CLI**: `mxcli catalog search "customer"`, `mxcli catalog show ` +- **Purpose**: Discover OData/REST/SOAP services across your organization +- **Requires**: Platform authentication (PAT token) +- **Data source**: Mendix cloud service + +**MDL CATALOG keyword** (different concept): +- **What**: Local project metadata tables in the mxcli SQLite database +- **MDL syntax**: `SELECT ... FROM CATALOG.entities`, `SHOW CATALOG TABLES` +- **Purpose**: Query project structure (entities, microflows, pages, etc.) +- **Requires**: `REFRESH CATALOG` command (no auth needed) +- **Data source**: Your local .mpr file + +See `.claude/skills/mendix/browse-integrations.md` for MDL CATALOG usage. + ## Related - Platform authentication: `.claude/skills/mendix/platform-auth.md` - OData client creation: `.claude/skills/mendix/odata-data-sharing.md` +- MDL CATALOG queries: `.claude/skills/mendix/browse-integrations.md` diff --git a/cmd/mxcli/cmd_catalog.go b/cmd/mxcli/cmd_catalog.go index 6a170192..ae67a2da 100644 --- a/cmd/mxcli/cmd_catalog.go +++ b/cmd/mxcli/cmd_catalog.go @@ -15,8 +15,12 @@ import ( var catalogCmd = &cobra.Command{ Use: "catalog", - Short: "Search and manage Mendix Catalog services", - Long: `Search for data sources and services registered in Mendix Catalog. + Short: "Search and manage Mendix Catalog services (catalog.mendix.com)", + Long: `Search for data sources and services registered in Mendix Catalog (catalog.mendix.com). + +NOTE: This is the external Mendix Catalog service, NOT the MDL CATALOG keyword. + - CLI catalog commands: Search external service registry (requires auth) + - MDL CATALOG keyword: Query local project metadata (SELECT ... FROM CATALOG.entities) Requires authentication via Personal Access Token (PAT). Create a PAT at: https://user-settings.mendix.com/ diff --git a/docs/11-proposals/PROPOSAL_catalog_integration.md b/docs/11-proposals/PROPOSAL_catalog_integration.md index acd19220..995ef72c 100644 --- a/docs/11-proposals/PROPOSAL_catalog_integration.md +++ b/docs/11-proposals/PROPOSAL_catalog_integration.md @@ -4,6 +4,8 @@ **Date:** 2026-04-16 **Author:** Generated with Claude Code +**⚠️ TERMINOLOGY NOTE:** This proposal covers the **external Mendix Catalog service** at catalog.mendix.com (CLI: `mxcli catalog search`), which is separate from the **MDL CATALOG keyword** used for querying local project metadata (`SELECT ... FROM CATALOG.entities`). The two concepts are unrelated despite sharing the name "catalog". + ## Problem Mendix Catalog (catalog.mendix.com) is the centralized registry for discovering data sources and services across an organization's landscape. It indexes OData services, REST APIs, SOAP services, and Business Events published by Mendix applications and external systems. From 1b5365451bdeded95c0e04bd0088517c7385c26c Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 18:25:03 +0200 Subject: [PATCH 09/10] style: run go fmt on catalog types --- internal/catalog/types.go | 64 +++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/internal/catalog/types.go b/internal/catalog/types.go index 0da0d395..37b5bed1 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -60,40 +60,40 @@ type Owner struct { // EndpointDetails represents the full response from GET /endpoints/{uuid}. type EndpointDetails struct { - UUID string `json:"uuid"` - Path string `json:"path"` - Location string `json:"location"` - Discoverable bool `json:"discoverable"` - Validated bool `json:"validated"` - SecurityClassification string `json:"securityClassification"` - Connections int `json:"connections"` - LastUpdated string `json:"lastUpdated"` - ServiceVersion ServiceVersion `json:"serviceVersion"` - Environment EnvironmentWithApp `json:"environment"` + UUID string `json:"uuid"` + Path string `json:"path"` + Location string `json:"location"` + Discoverable bool `json:"discoverable"` + Validated bool `json:"validated"` + SecurityClassification string `json:"securityClassification"` + Connections int `json:"connections"` + LastUpdated string `json:"lastUpdated"` + ServiceVersion ServiceVersion `json:"serviceVersion"` + Environment EnvironmentWithApp `json:"environment"` } // EnvironmentWithApp extends Environment with nested Application (used in endpoint details). type EnvironmentWithApp struct { - Name string `json:"name"` - Location string `json:"location"` - Type string `json:"type"` - UUID string `json:"uuid"` + Name string `json:"name"` + Location string `json:"location"` + Type string `json:"type"` + UUID string `json:"uuid"` Application Application `json:"application"` } // ServiceVersion contains version-specific metadata and contract. type ServiceVersion struct { - Version string `json:"version"` - Description string `json:"description"` - UUID string `json:"uuid"` - PublishDate string `json:"publishDate"` - Type string `json:"type"` // "OData", "REST", "SOAP" - Contracts []Contract `json:"contracts"` - SecurityScheme *SecurityScheme `json:"securityScheme,omitempty"` - TotalEntities int `json:"totalEntities"` - Entities []Entity `json:"entities"` - TotalActions int `json:"totalActions"` - Actions []Action `json:"actions"` + Version string `json:"version"` + Description string `json:"description"` + UUID string `json:"uuid"` + PublishDate string `json:"publishDate"` + Type string `json:"type"` // "OData", "REST", "SOAP" + Contracts []Contract `json:"contracts"` + SecurityScheme *SecurityScheme `json:"securityScheme,omitempty"` + TotalEntities int `json:"totalEntities"` + Entities []Entity `json:"entities"` + TotalActions int `json:"totalActions"` + Actions []Action `json:"actions"` } // Contract represents an API contract (metadata, OpenAPI, WSDL, etc). @@ -168,13 +168,13 @@ type Association struct { // Action represents an OData action or function. type Action struct { - Name string `json:"name"` - FullyQualifiedName string `json:"fullyQualifiedName"` - Summary string `json:"summary"` - Description string `json:"description"` - TotalParameters int `json:"totalParameters"` - Parameters []Parameter `json:"parameters"` - ReturnType *ReturnType `json:"returnType,omitempty"` + Name string `json:"name"` + FullyQualifiedName string `json:"fullyQualifiedName"` + Summary string `json:"summary"` + Description string `json:"description"` + TotalParameters int `json:"totalParameters"` + Parameters []Parameter `json:"parameters"` + ReturnType *ReturnType `json:"returnType,omitempty"` } // Parameter represents an action/function parameter. From 3a81bbbf468bde50b4e1f294c09b682e56e0c7d7 Mon Sep 17 00:00:00 2001 From: Dionesius Perkasa Date: Thu, 16 Apr 2026 18:43:02 +0200 Subject: [PATCH 10/10] test: add unit tests for catalog commands Add command-level tests for catalog search and show: - Test authentication requirement (no auth errors) - Test required arguments (query for search, uuid for show) - Tests validate error messages guide users to 'mxcli auth login' Catalog client and types tests were already present in the codebase. Co-Authored-By: Claude Sonnet 4.5 --- cmd/mxcli/cmd_catalog_test.go | 83 +++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 cmd/mxcli/cmd_catalog_test.go diff --git a/cmd/mxcli/cmd_catalog_test.go b/cmd/mxcli/cmd_catalog_test.go new file mode 100644 index 00000000..7aecfa80 --- /dev/null +++ b/cmd/mxcli/cmd_catalog_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// runCatalog executes the catalog subtree with the given args. +func runCatalog(t *testing.T, args ...string) (string, error) { + t.Helper() + for _, c := range []*cobra.Command{catalogSearchCmd, catalogShowCmd} { + resetCmdFlags(c) + } + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs(append([]string{"catalog"}, args...)) + err := rootCmd.ExecuteContext(context.Background()) + return out.String(), err +} + +func TestCatalogSearch_NoAuth(t *testing.T) { + withTestHome(t) + + _, err := runCatalog(t, "search", "test") + if err == nil { + t.Fatal("expected error when not authenticated") + } + // Error message should hint at auth login + errMsg := err.Error() + if !strings.Contains(errMsg, "auth login") && !strings.Contains(errMsg, "credential") && !strings.Contains(errMsg, "no credential") { + t.Errorf("error should mention auth or credential: %v", err) + } +} + +func TestCatalogShow_NoAuth(t *testing.T) { + withTestHome(t) + + _, err := runCatalog(t, "show", "test-uuid") + if err == nil { + t.Fatal("expected error when not authenticated") + } + // Error message should hint at auth + errMsg := err.Error() + if !strings.Contains(errMsg, "auth login") && !strings.Contains(errMsg, "credential") && !strings.Contains(errMsg, "no credential") { + t.Errorf("error should mention auth or credential: %v", err) + } +} + +func TestCatalogSearch_RequiresQuery(t *testing.T) { + withTestHome(t) + + _, err := runCatalog(t, "search") + if err == nil { + t.Fatal("expected error when query is missing") + } + // Should mention that query argument is required + errMsg := err.Error() + if !strings.Contains(errMsg, "requires") && !strings.Contains(errMsg, "arg") { + t.Errorf("error should mention missing argument: %v", err) + } +} + +func TestCatalogShow_RequiresUUID(t *testing.T) { + withTestHome(t) + + _, err := runCatalog(t, "show") + if err == nil { + t.Fatal("expected error when UUID is missing") + } + // Should mention that UUID argument is required + errMsg := err.Error() + if !strings.Contains(errMsg, "requires") && !strings.Contains(errMsg, "arg") { + t.Errorf("error should mention missing argument: %v", err) + } +}