diff --git a/.claude/skills/mendix/write-lint-rules/SKILL.md b/.claude/skills/mendix/write-lint-rules/SKILL.md index 99eccf45bb..02c18c27f0 100644 --- a/.claude/skills/mendix/write-lint-rules/SKILL.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -57,6 +57,8 @@ silently return empty results (issue #721). | `widgets()` | list of widget | All non-system widgets | | `snippets()` | list of snippet | All non-system snippets | | `scheduled_events()` | list of scheduled_event | All non-system scheduled events (requires MPR reader) | +| `rest_clients()` | list of rest_client | Consumed REST service documents (excluding platform modules) | +| `rest_operations()` | list of rest_operation | Operations on consumed REST services, including their `timeout` | | `attributes_for(entity_qualified_name)` | list of attribute | Attributes for a specific entity | | `activities_for(microflow_qualified_name)` | list of activity | Activities for a microflow (requires FULL catalog) | | `permissions()` | list of permission | All permissions across all element types | @@ -321,6 +323,34 @@ def count_not(node): | `module_name` | string | `"Sales"` | | `entity_ref` | string | Referenced entity qualified name | +### rest_client +| Property | Type | Example | +|----------|------|---------| +| `id` | string | Document UUID | +| `name` | string | `"CustomerApi"` | +| `qualified_name` | string | `"Sales.CustomerApi"` | +| `module_name` | string | `"Sales"` | +| `folder` | string | Folder path within module | +| `base_url` | string | `"https://api.example.com/v1"` | +| `auth_scheme` | string | Authentication scheme, empty when none | +| `operation_count` | int | Number of operations on the service | +| `documentation` | string | Documentation text | + +### rest_operation +| Property | Type | Example | +|----------|------|---------| +| `id` | string | Operation UUID | +| `service_id` | string | Owning service UUID | +| `service_qualified_name` | string | `"Sales.CustomerApi"` | +| `name` | string | `"GetCustomer"` | +| `http_method` | string | `"GET"`, `"POST"`, … | +| `path` | string | `"/customers/{id}"` | +| `parameter_count` | int | Number of parameters | +| `has_body` | bool | True when the request carries a body | +| `response_type` | string | Response type name | +| `timeout` | int | Configured timeout in milliseconds; `0` when none is set | +| `module_name` | string | `"Sales"` | + ### permission Returned by `permissions()` (all types) or `permissions_for()` (entity-specific). diff --git a/mdl/linter/context.go b/mdl/linter/context.go index efa313f8d7..70b111ae25 100644 --- a/mdl/linter/context.go +++ b/mdl/linter/context.go @@ -1136,6 +1136,127 @@ func (ctx *LintContext) DatabaseConnections() iter.Seq[DatabaseConnection] { } } +// RestClient represents a consumed REST service document from the +// rest_clients table. +type RestClient struct { + ID string + Name string + QualifiedName string + ModuleName string + Folder string + BaseUrl string + AuthScheme string + OperationCount int + Documentation string +} + +// RestClients returns an iterator over all consumed REST services +// (excluding platform modules). +// +// Backed by the catalog rather than the reader, like DatabaseConnections. +func (ctx *LintContext) RestClients() iter.Seq[RestClient] { + return func(yield func(RestClient) bool) { + rows, err := ctx.db.Query(fmt.Sprintf(` + SELECT rc.Id, rc.Name, rc.QualifiedName, rc.ModuleName, rc.Folder, + rc.BaseUrl, rc.AuthScheme, rc.OperationCount, rc.Documentation + FROM rest_clients rc + LEFT JOIN modules m ON rc.ModuleName = m.Name + WHERE %s + ORDER BY rc.ModuleName, rc.Name + `, notPlatformModule("m"))) + if err != nil { + ctx.recordQueryError("RestClients", err) + return + } + defer rows.Close() + + for rows.Next() { + var rc RestClient + var folder, baseURL, authScheme, documentation sql.NullString + err := rows.Scan(&rc.ID, &rc.Name, &rc.QualifiedName, &rc.ModuleName, &folder, + &baseURL, &authScheme, &rc.OperationCount, &documentation) + if err != nil { + ctx.recordQueryError("RestClients (row scan)", err) + continue + } + rc.Folder = folder.String + rc.BaseUrl = baseURL.String + rc.AuthScheme = authScheme.String + rc.Documentation = documentation.String + + if ctx.IsExcluded(rc.ModuleName) { + continue + } + + if !yield(rc) { + return + } + } + } +} + +// RestOperation represents one operation on a consumed REST service. +type RestOperation struct { + ID string + ServiceID string + ServiceQualifiedName string + Name string + HttpMethod string + Path string + ParameterCount int + HasBody bool + ResponseType string + // Timeout is the configured timeout in milliseconds; 0 when none is set. + Timeout int + ModuleName string +} + +// RestOperations returns an iterator over all consumed REST operations +// (excluding platform modules). +func (ctx *LintContext) RestOperations() iter.Seq[RestOperation] { + return func(yield func(RestOperation) bool) { + rows, err := ctx.db.Query(fmt.Sprintf(` + SELECT ro.Id, ro.ServiceId, ro.ServiceQualifiedName, ro.Name, + ro.HttpMethod, ro.Path, ro.ParameterCount, ro.HasBody, + ro.ResponseType, ro.Timeout, ro.ModuleName + FROM rest_operations ro + LEFT JOIN modules m ON ro.ModuleName = m.Name + WHERE %s + ORDER BY ro.ServiceQualifiedName, ro.Name + `, notPlatformModule("m"))) + if err != nil { + ctx.recordQueryError("RestOperations", err) + return + } + defer rows.Close() + + for rows.Next() { + var ro RestOperation + var hasBody int + var httpMethod, path, responseType sql.NullString + err := rows.Scan(&ro.ID, &ro.ServiceID, &ro.ServiceQualifiedName, &ro.Name, + &httpMethod, &path, &ro.ParameterCount, &hasBody, + &responseType, &ro.Timeout, &ro.ModuleName) + if err != nil { + ctx.recordQueryError("RestOperations (row scan)", err) + continue + } + ro.HttpMethod = httpMethod.String + ro.Path = path.String + ro.ResponseType = responseType.String + ro.HasBody = hasBody != 0 + + if ctx.IsExcluded(ro.ModuleName) { + continue + } + + if !yield(ro) { + return + } + } + } +} + // Activity represents an activity from the activities table (FULL catalog mode). type Activity struct { ID string diff --git a/mdl/linter/starlark.go b/mdl/linter/starlark.go index a74745b641..369ea3c03f 100644 --- a/mdl/linter/starlark.go +++ b/mdl/linter/starlark.go @@ -343,6 +343,8 @@ func (r *StarlarkRule) buildPredeclared() starlark.StringDict { "permissions_for": starlark.NewBuiltin("permissions_for", r.builtinPermissionsFor), "snippets": starlark.NewBuiltin("snippets", r.builtinSnippets), "database_connections": starlark.NewBuiltin("database_connections", r.builtinDatabaseConnections), + "rest_clients": starlark.NewBuiltin("rest_clients", r.builtinRestClients), + "rest_operations": starlark.NewBuiltin("rest_operations", r.builtinRestOperations), "activities_for": starlark.NewBuiltin("activities_for", r.builtinActivitiesFor), // Project-level queries @@ -655,6 +657,34 @@ func (r *StarlarkRule) builtinDatabaseConnections(_ *starlark.Thread, _ *starlar return starlark.NewList(connections), nil } +// builtinRestClients returns all consumed REST service documents. +func (r *StarlarkRule) builtinRestClients(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if r.ctx == nil { + return starlark.NewList(nil), nil + } + + var clients []starlark.Value + for rc := range r.ctx.RestClients() { + clients = append(clients, restClientToStarlark(rc)) + } + + return starlark.NewList(clients), nil +} + +// builtinRestOperations returns all consumed REST operations. +func (r *StarlarkRule) builtinRestOperations(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + if r.ctx == nil { + return starlark.NewList(nil), nil + } + + var operations []starlark.Value + for ro := range r.ctx.RestOperations() { + operations = append(operations, restOperationToStarlark(ro)) + } + + return starlark.NewList(operations), nil +} + // builtinScheduledEvents returns all scheduled events. func (r *StarlarkRule) builtinScheduledEvents(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { if r.ctx == nil { @@ -1056,6 +1086,38 @@ func activityToStarlark(a Activity) starlark.Value { }) } +// restClientToStarlark converts a RestClient to a Starlark struct. +func restClientToStarlark(rc RestClient) starlark.Value { + return starlarkstruct.FromStringDict(starlark.String("rest_client"), starlark.StringDict{ + "id": starlark.String(rc.ID), + "name": starlark.String(rc.Name), + "qualified_name": starlark.String(rc.QualifiedName), + "module_name": starlark.String(rc.ModuleName), + "folder": starlark.String(rc.Folder), + "base_url": starlark.String(rc.BaseUrl), + "auth_scheme": starlark.String(rc.AuthScheme), + "operation_count": starlark.MakeInt(rc.OperationCount), + "documentation": starlark.String(rc.Documentation), + }) +} + +// restOperationToStarlark converts a RestOperation to a Starlark struct. +func restOperationToStarlark(ro RestOperation) starlark.Value { + return starlarkstruct.FromStringDict(starlark.String("rest_operation"), starlark.StringDict{ + "id": starlark.String(ro.ID), + "service_id": starlark.String(ro.ServiceID), + "service_qualified_name": starlark.String(ro.ServiceQualifiedName), + "name": starlark.String(ro.Name), + "http_method": starlark.String(ro.HttpMethod), + "path": starlark.String(ro.Path), + "parameter_count": starlark.MakeInt(ro.ParameterCount), + "has_body": starlark.Bool(ro.HasBody), + "response_type": starlark.String(ro.ResponseType), + "timeout": starlark.MakeInt(ro.Timeout), + "module_name": starlark.String(ro.ModuleName), + }) +} + // databaseConnectionToStarlark converts a DatabaseConnection to a Starlark struct. func databaseConnectionToStarlark(dc DatabaseConnection) starlark.Value { return starlarkstruct.FromStringDict(starlark.String("database_connection"), starlark.StringDict{