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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@
* `AppConfig` validation errors no longer render twice — removed a redundant `valueType`
validator that re-ran the `value` check and emitted a duplicate generic message.

### ⚙️ Technical

* Added `ConfigSpec`, `PreferenceSpec`, and `RoleSpec` typed classes for use with
`ensureRequiredConfigsCreated()`, `ensureRequiredPrefsCreated()`, and
`ensureRequiredRolesCreated()`, replacing untyped `Map` arguments with classes that provide IDE
autocomplete and compile-time validation. Previous `Map`-based signatures remain supported as
deprecated overloads and will be removed in v42. `ConfigSpec.typedClass` is the supported way to
register a `TypedConfigMap` subclass against a JSON config.

### 🤖 AI Docs + Tooling

* **New `coding-conventions.md` doc** — authoritative coding conventions reference for hoist-core, consolidating guidance previously scattered across `CLAUDE.md` and individual feature docs. Paired sibling to the hoist-react `coding-conventions.md`. Covers naming, logging (`LogSupport`, `withInfo`/`withDebug`, structured map form), exceptions, services and lifecycle, controllers and security, GORM, clustering, HTTP/email/background work, Groovy idioms, and commit/PR formatting.
Expand Down
20 changes: 14 additions & 6 deletions docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ establishes *who* the user is, authorization determines *what* they can do.
| `DefaultRoleAdminService` | `grails-app/services/io/xh/hoist/role/provided/` | Admin Console read operations (role listing with effective memberships) |
| `DefaultRoleUpdateService` | `grails-app/services/io/xh/hoist/role/provided/` | Role mutations (CRUD, `ensureRequiredRolesCreated`, `assignRole`) |
| `RoleAdminController` | `grails-app/controllers/io/xh/hoist/admin/` | Admin Console endpoints for role management |
| `RoleSpec` | `src/main/groovy/io/xh/hoist/role/provided/` | Typed specification for required role definitions |
| `Role` | `grails-app/domain/io/xh/hoist/role/provided/` | GORM domain — role definitions |
| `RoleMember` | `grails-app/domain/io/xh/hoist/role/provided/` | GORM domain — role memberships |
| `HoistInterceptor` | `grails-app/controllers/io/xh/hoist/` | Grails interceptor enforcing annotations |
Expand Down Expand Up @@ -158,6 +159,7 @@ The simplest approach — extend it as your app's `RoleService`:
package com.myapp

import io.xh.hoist.role.provided.DefaultRoleService
import io.xh.hoist.role.provided.RoleSpec

class RoleService extends DefaultRoleService {

Expand All @@ -166,9 +168,9 @@ class RoleService extends DefaultRoleService {

// Create app-specific roles (no-op if they already exist)
ensureRequiredRolesCreated([
[name: 'APP_USER', category: 'App', notes: 'Standard application access', roles: ['APP_ADMIN']],
[name: 'APP_ADMIN', category: 'App', notes: 'Full admin access'],
[name: 'TRADER', category: 'Trading', notes: 'Can execute trades']
new RoleSpec(name: 'APP_USER', category: 'App', notes: 'Standard application access', roles: ['APP_ADMIN']),
new RoleSpec(name: 'APP_ADMIN', category: 'App', notes: 'Full admin access'),
new RoleSpec(name: 'TRADER', category: 'Trading', notes: 'Can execute trades')
])
}
}
Expand All @@ -190,8 +192,8 @@ role definition means "members of these listed roles also get this role":

```groovy
ensureRequiredRolesCreated([
[name: 'APP_USER', category: 'App', roles: ['APP_ADMIN']], // admins also get APP_USER
[name: 'APP_ADMIN', category: 'App'],
new RoleSpec(name: 'APP_USER', category: 'App', roles: ['APP_ADMIN']), // admins also get APP_USER
new RoleSpec(name: 'APP_ADMIN', category: 'App'),
])
```

Expand All @@ -205,7 +207,7 @@ via `LdapService`:

```groovy
ensureRequiredRolesCreated([
[name: 'APP_USER', directoryGroups: ['CN=AppUsers,OU=Groups,DC=company,DC=com']]
new RoleSpec(name: 'APP_USER', directoryGroups: ['CN=AppUsers,OU=Groups,DC=company,DC=com'])
])
```

Expand Down Expand Up @@ -312,6 +314,12 @@ has `HOIST_ROLE_MANAGER`. This prevents an admin from impersonating a role manag
its database, and provides a complete Admin Console UI for managing roles. Most applications should
extend it and create app-specific roles in `ensureRequiredConfigAndRolesCreated()`.

The `ensureRequiredRolesCreated()` method accepts a `List<RoleSpec>` where each `RoleSpec` specifies
the role's `name` and optional fields (`category`, `notes`, `users`, `directoryGroups`, `roles`).
It creates any missing roles with the supplied defaults — existing roles are never modified. A
deprecated overload accepting `List<Map>` is still supported for backward compatibility but should
be migrated to `RoleSpec`.

### Custom RoleService

Hoist is deliberately flexible about where role assignments come from. Some applications or customer
Expand Down
81 changes: 60 additions & 21 deletions docs/coding-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,37 +403,76 @@ case.

### Bootstrap Required Resources

Services that depend on specific configs, prefs, or roles should declare them in the corresponding
`ensureRequired*Created()` hook. The framework runs these during bootstrap so a fresh database comes
up with the rows needed:
Apps declare the configs, prefs, and roles they depend on so a fresh database comes up with the
rows needed. Use the typed spec classes (`ConfigSpec`, `PreferenceSpec`, `RoleSpec`) — they give
you IDE autocomplete, compile-time validation of field names, and a stable contract that mirrors
the seedable fields of the underlying domain class.

Configs and prefs are typically declared from `BootStrap.groovy` by calling the corresponding
service:

```groovy
class PortfolioService extends BaseService {
import io.xh.hoist.config.ConfigSpec
import io.xh.hoist.pref.PreferenceSpec

Map<String, Map> getRequiredConfigs() {
[
myAppPortfolioRefreshInterval: [
valueType : 'int',
class BootStrap {

def configService
def prefService

def init = { servletContext ->
configService.ensureRequiredConfigsCreated([
new ConfigSpec(
name: 'myAppPortfolioRefreshInterval',
valueType: 'int',
defaultValue: 60,
groupName : 'PortfolioService',
note : 'Refresh interval in seconds'
]
]
groupName: 'PortfolioService',
note: 'Refresh interval in seconds'
),
new ConfigSpec(
name: 'myAppPricingSource',
valueType: 'json',
defaultValue: [endpoint: 'https://prices.example.com'],
typedClass: PricingConfig,
groupName: 'PortfolioService'
)
])

prefService.ensureRequiredPrefsCreated([
new PreferenceSpec(
name: 'myAppPortfolioDefaultView',
type: 'string',
defaultValue: 'summary',
groupName: 'PortfolioService'
)
])
}
}
```

Map<String, Map> getRequiredPrefs() {
[
myAppPortfolioDefaultView: [
type : 'string',
defaultValue: 'summary',
groupName : 'PortfolioService'
]
]
Roles are declared by overriding `ensureRequiredConfigAndRolesCreated()` in the app's
`RoleService` (extending `DefaultRoleService`):

```groovy
import io.xh.hoist.role.provided.DefaultRoleService
import io.xh.hoist.role.provided.RoleSpec

class RoleService extends DefaultRoleService {

protected void ensureRequiredConfigAndRolesCreated() {
super.ensureRequiredConfigAndRolesCreated()

ensureRequiredRolesCreated([
new RoleSpec(name: 'APP_USER', category: 'App', notes: 'Standard access', roles: ['APP_ADMIN']),
new RoleSpec(name: 'APP_ADMIN', category: 'App', notes: 'Full admin access'),
new RoleSpec(name: 'TRADER', category: 'Trading', notes: 'Can execute trades')
])
}
}
```

See [Configuration](configuration.md) and [Preferences](preferences.md) for full schemas.
See [Configuration](configuration.md), [Preferences](preferences.md), and
[Authorization](authorization.md) for the full schemas.

### Use `configService` Typed Getters

Expand Down
38 changes: 25 additions & 13 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ Key capabilities:
| `AppConfig` | `grails-app/domain/io/xh/hoist/config/` | GORM domain — database-backed config entries |
| `ConfigService` | `grails-app/services/io/xh/hoist/config/` | Primary service — typed getters, event publishing |
| `ConfigDiffService` | `grails-app/services/io/xh/hoist/config/` | Cross-environment config synchronization |
| `ConfigSpec` | `src/main/groovy/io/xh/hoist/config/` | Typed specification for required config definitions |
| `ConfigAdminController` | `grails-app/controllers/io/xh/hoist/admin/` | Admin console endpoint for config management |

### Key Classes
Expand Down Expand Up @@ -294,8 +295,8 @@ the requested type doesn't match the config's `valueType`.
##### Typed configs via `TypedConfigMap`

For structured JSON configs with a stable, known key set, declare a `TypedConfigMap` subclass
and wire it in via the `typedClass:` key on the config's `ensureRequiredConfigsCreated`
entry. This gives you:
and wire it in via the `typedClass:` field on the config's `ConfigSpec` entry passed to
`ensureRequiredConfigsCreated`. This gives you:

1. **One source of truth for shape + defaults.** Property initializers on the class are the
fallback values; when the stored map is missing a key, the declared default applies.
Expand Down Expand Up @@ -327,13 +328,14 @@ class PricingConfig extends TypedConfigMap {

// 2. Register it in BootStrap alongside the other config metadata.
configService.ensureRequiredConfigsCreated([
pricingSourceConfig: [
new ConfigSpec(
name: 'pricingSourceConfig',
valueType: 'json',
defaultValue: [endpoint: 'https://prices.example.com', timeoutMs: 5000, fallbackEnabled: true],
typedClass: PricingConfig,
groupName: 'Pricing',
note: '...'
]
)
])

// 3. Read it, anywhere.
Expand Down Expand Up @@ -401,39 +403,49 @@ obscured as `'*********'`, and JSON values are parsed to objects.

##### `ensureRequiredConfigsCreated(reqConfigs)`

Called during application bootstrap to declare configs the application depends on. Creates missing
configs with default values; logs errors for type mismatches or `clientVisible` flag mismatches on
existing configs (but does not auto-fix them).
Called during application bootstrap to declare configs the application depends on. Accepts a
`List<ConfigSpec>` where each `ConfigSpec` specifies the config's `name`, `valueType`,
`defaultValue`, and optional fields (`clientVisible`, `groupName`, `note`). Creates missing configs
with default values; logs errors for type mismatches or `clientVisible` flag mismatches on existing
configs (but does not auto-fix them).

Applications should declare all long-lived, expected configs here — not just those that are
strictly required at startup. This serves as an effective inventory of the application's soft
configs, ensures that an app starting against a fresh database has a complete set of entries
visible and adjustable in the Admin Console, and guarantees consistency across environments.

A deprecated overload accepting `Map<String, Map>` (where the outer key is the config name) is
still supported for backward compatibility but should be migrated to `ConfigSpec`.

```groovy
import io.xh.hoist.config.ConfigSpec

class BootStrap {
def configService

def init = {
configService.ensureRequiredConfigsCreated([
'apiEndpoint': [
new ConfigSpec(
name: 'apiEndpoint',
valueType: 'string',
defaultValue: 'https://api.example.com',
clientVisible: true,
groupName: 'API',
note: 'External API base URL'
],
'maxConnections': [
),
new ConfigSpec(
name: 'maxConnections',
valueType: 'int',
defaultValue: '100',
groupName: 'Performance'
],
'apiSecret': [
),
new ConfigSpec(
name: 'apiSecret',
valueType: 'pwd',
defaultValue: 'changeme',
groupName: 'API',
note: 'API authentication secret'
]
)
])
}
}
Expand Down
38 changes: 25 additions & 13 deletions docs/preferences.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ application settings, while preferences are per-user.
| `UserPreference` | `grails-app/domain/io/xh/hoist/pref/` | GORM domain — per-user preference values |
| `PrefService` | `grails-app/services/io/xh/hoist/pref/` | Primary service — typed getters/setters |
| `PrefDiffService` | `grails-app/services/io/xh/hoist/pref/` | Cross-environment preference synchronization |
| `PreferenceSpec` | `src/main/groovy/io/xh/hoist/pref/` | Typed specification for required preference definitions |
| `PreferenceAdminController` | `grails-app/controllers/io/xh/hoist/admin/` | Admin Console CRUD endpoints for preference definitions |
| `XhController` | `grails-app/controllers/io/xh/hoist/impl/` | Client-facing `getPrefs` / `setPrefs` endpoints |

Expand Down Expand Up @@ -158,35 +159,46 @@ parsed to objects.
#### `ensureRequiredPrefsCreated(requiredPrefs)`

Applications should register all preferences they intend to use via this method in their
`BootStrap.groovy`. A preference must exist as a `Preference` record in the database before it can
be read or written — calls to `PrefService` for a non-existent preference will throw a
`RuntimeException`. This method creates any missing preferences with the supplied defaults and logs
errors if an existing preference has a mismatched type. Note the API uses `note` (singular) for
consistency with `ensureRequiredConfigsCreated()`, even though the `Preference` domain property is
`notes` (plural).
`BootStrap.groovy`. Accepts a `List<PreferenceSpec>` where each `PreferenceSpec` specifies the
preference's `name`, `type`, `defaultValue`, and optional fields (`groupName`, `notes`). A
preference must exist as a `Preference` record in the database before it can be read or written —
calls to `PrefService` for a non-existent preference will throw a `RuntimeException`. This method
creates any missing preferences with the supplied defaults and logs errors if an existing
preference has a mismatched type.

A deprecated overload accepting `Map<String, Map>` (where the outer key is the preference name)
is still supported for backward compatibility but should be migrated to `PreferenceSpec`. Note that
the old Map API used `note` (singular) for consistency with the former `ensureRequiredConfigsCreated()`
Map API; the deprecated overload maps `note` to the correct `notes` (plural) field on `Preference`
automatically.

```groovy
import io.xh.hoist.pref.PreferenceSpec

class BootStrap {
def prefService

def init = {
prefService.ensureRequiredPrefsCreated([
'theme': [
new PreferenceSpec(
name: 'theme',
type: 'string',
defaultValue: 'light',
groupName: 'UI',
note: 'User interface theme'
],
'defaultPageSize': [
notes: 'User interface theme'
),
new PreferenceSpec(
name: 'defaultPageSize',
type: 'int',
defaultValue: '25',
groupName: 'UI'
],
'dashboardLayout': [
),
new PreferenceSpec(
name: 'dashboardLayout',
type: 'json',
defaultValue: [panels: []],
groupName: 'Dashboard'
]
)
])
}
}
Expand Down
Loading
Loading