diff --git a/CLAUDE.md b/CLAUDE.md index 584cb9b..03c8c85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,8 +96,8 @@ plugins (incl. ref) depend on: api/api-v1 only **Foundation complete; first business surface in place.** As of the latest commit: - **13 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. -- **118 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build. -- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader (P1.5), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. +- **129 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build. +- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. - **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts). - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. - **Package root** is `org.vibeerp`. diff --git a/PROGRESS.md b/PROGRESS.md index 87ff786..e3ab6ca 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,27 +10,27 @@ | | | |---|---| -| **Latest version** | v0.8 (post-P1.6) | -| **Latest commit** | `01c71a6 feat(i18n): P1.6 — ICU4J translator + per-plug-in locale chain` | +| **Latest version** | v0.9 (post-P3.4) | +| **Latest commit** | `feat(metadata): P3.4 — custom field application (Tier 1 customization)` | | **Repo** | https://github.com/reporkey/vibe-erp | | **Modules** | 13 | -| **Unit tests** | 118, all green | -| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales | +| **Unit tests** | 129, all green | +| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales; Partner accepts custom-field `ext` declared in metadata | | **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | ## Current stage -**Foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class. +**Foundation complete; Tier 1 customization live; business surface area growing.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). The metadata layer now drives **custom-field validation on JSONB `ext` columns** (P3.4), the cornerstone of the "key user adds fields without code" promise: a YAML declaration is enough to start enforcing types, scales, enums, and required-checks on every save. Three real PBCs (identity, catalog, partners) validate the modular-monolith template; Partner is the first to use custom fields. The reference printing-shop plug-in remains the executable acceptance test. -The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA. +The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), permission enforcement against `metadata__role_permission`, and eventually the React SPA. ## Total scope (the v1.0 cut line) The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. -That target breaks down into roughly 30 work units across 8 phases. About **17 are done** as of today. Below is the full list with status. +That target breaks down into roughly 30 work units across 8 phases. About **18 are done** as of today. Below is the full list with status. ### Phase 1 — Platform completion (foundation) @@ -62,7 +62,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **17 a | P3.1 | JSON Schema form renderer (server) | 🔜 Pending | | P3.2 | Form renderer (web) | 🔜 Pending — depends on R1 | | P3.3 | Form designer (web) | 🔜 Pending — depends on R1 | -| P3.4 | Custom field application (JSONB `ext` validation) | ⏳ Next priority candidate | +| P3.4 | Custom field application (JSONB `ext` validation) | ✅ DONE — `` | | P3.5 | Rules engine (event-driven) | 🔜 Pending | | P3.6 | List view designer (web) | 🔜 Pending — depends on R1 | @@ -127,6 +127,7 @@ These are the cross-cutting platform services already wired into the running fra | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. | | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. | | **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_.properties` resolves before the host's `messages_.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. | +| **Custom field application** (P3.4) | `platform-metadata.customfield` | `CustomFieldRegistry` reads `metadata__custom_field` rows into an in-memory index keyed by entity name, refreshed at boot and after every plug-in load. `ExtJsonValidator` validates the JSONB `ext` map of any entity against the declared `FieldType`s — String maxLength, Integer/Decimal numeric coercion with precision/scale enforcement, Boolean, Date/DateTime ISO-8601 parsing, Enum allowed values, UUID format. Unknown keys are rejected; required missing fields are rejected; ALL violations are returned in a single 400 so a form submitter fixes everything in one round-trip. `Partner` is the first PBC entity to wire ext through `PartnerService.create/update`; the public `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the api.v1 runtime view of declarations to the SPA / OpenAPI / AI agent. | | **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1//` → cross-PBC facade in `api.v1.ext.` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. | ## What the reference plug-in proves end-to-end @@ -163,7 +164,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, ## What's not yet live (the deferred list) - **Workflow engine.** No Flowable yet. The api.v1 `TaskHandler` interface exists; the runtime that calls it doesn't. -- **Custom fields.** The `ext jsonb` columns exist on every entity; the metadata describing them exists; the validator that enforces them on save does not (P3.4). - **Forms.** No JSON Schema form renderer (server or client). No form designer. - **Reports.** No JasperReports. - **File store.** No abstraction; no S3 backend. diff --git a/README.md b/README.md index 3971b52..0ab78a6 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ vibe-erp/ ## Building ```bash -# Build everything (compiles 13 modules, runs 118 unit tests) +# Build everything (compiles 13 modules, runs 129 unit tests) ./gradlew build # Bring up Postgres + the reference plug-in JAR @@ -92,12 +92,12 @@ The bootstrap admin password is printed to the application logs on first boot. A ## Status -**Current stage: foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, the metadata seeder, and the ICU4J translator with per-plug-in locale chain. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class. +**Current stage: foundation complete; Tier 1 customization live; business surface area growing.** All eight cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, the metadata seeder + custom-field validator, and the ICU4J translator with per-plug-in locale chain. The Tier 1 customization story works end-to-end: a YAML declaration adds a typed custom field to an existing entity, the framework's `ExtJsonValidator` enforces it on every save, and the SPA-facing `GET /api/v1/_meta/metadata/custom-fields/{entityName}` endpoint serves the runtime view to the form builder. | | | |---|---| | Modules | 13 | -| Unit tests | 118, all green | +| Unit tests | 129, all green | | Real PBCs | 3 of 10 | | Cross-cutting services live | 8 | | Plug-ins serving HTTP | 1 (reference printing-shop) | diff --git a/pbc/pbc-partners/build.gradle.kts b/pbc/pbc-partners/build.gradle.kts index ea019aa..f26483e 100644 --- a/pbc/pbc-partners/build.gradle.kts +++ b/pbc/pbc-partners/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { api(project(":api:api-v1")) implementation(project(":platform:platform-persistence")) implementation(project(":platform:platform-security")) + implementation(project(":platform:platform-metadata")) // for ExtJsonValidator (P3.4) implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) diff --git a/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt b/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt index 459c7b9..114b3bd 100644 --- a/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt +++ b/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt @@ -1,5 +1,7 @@ package org.vibeerp.pbc.partners.application +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.vibeerp.pbc.partners.domain.Partner @@ -7,6 +9,7 @@ import org.vibeerp.pbc.partners.domain.PartnerType import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator import java.util.UUID /** @@ -44,8 +47,11 @@ class PartnerService( private val partners: PartnerJpaRepository, private val addresses: AddressJpaRepository, private val contacts: ContactJpaRepository, + private val extValidator: ExtJsonValidator, ) { + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() + @Transactional(readOnly = true) fun list(): List = partners.findAll() @@ -59,6 +65,10 @@ class PartnerService( require(!partners.existsByCode(command.code)) { "partner code '${command.code}' is already taken" } + // Validate the ext JSONB before save (P3.4 — Tier 1 customization). + // Throws IllegalArgumentException with all violations on failure; + // GlobalExceptionHandler maps it to 400 Bad Request. + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) return partners.save( Partner( code = command.code, @@ -69,7 +79,9 @@ class PartnerService( email = command.email, phone = command.phone, active = command.active, - ), + ).also { + it.ext = jsonMapper.writeValueAsString(canonicalExt) + }, ) } @@ -86,10 +98,43 @@ class PartnerService( command.email?.let { partner.email = it } command.phone?.let { partner.phone = it } command.active?.let { partner.active = it } + // Ext is updated as a whole, not field-by-field — partial ext + // updates would force callers to merge with the existing JSON + // before sending, which is the kind of foot-gun the framework + // should not normalise. PATCH is for top-level columns only. + if (command.ext != null) { + val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) + partner.ext = jsonMapper.writeValueAsString(canonicalExt) + } return partner } /** + * Parse the entity's `ext` JSON string back into a Map for the + * REST response. Returns an empty map when the column is empty + * or unparseable — the latter shouldn't happen because every + * write goes through [extValidator], but cleaning up bad rows + * from old data isn't this method's responsibility. + */ + @Suppress("UNCHECKED_CAST") + fun parseExt(partner: Partner): Map = try { + if (partner.ext.isBlank()) emptyMap() + else jsonMapper.readValue(partner.ext, Map::class.java) as Map + } catch (ex: Throwable) { + emptyMap() + } + + companion object { + /** + * The entity name the partner aggregate is registered under in + * `metadata__entity` (and therefore the key the [ExtJsonValidator] + * looks up). Kept as a constant so the controller and tests + * can reference the same string without hard-coding it twice. + */ + const val ENTITY_NAME: String = "Partner" + } + + /** * Deactivate the partner and all its contacts. Addresses are left * alone (they have no `active` flag — see [Address]). */ @@ -111,6 +156,7 @@ data class CreatePartnerCommand( val email: String? = null, val phone: String? = null, val active: Boolean = true, + val ext: Map? = null, ) data class UpdatePartnerCommand( @@ -121,4 +167,5 @@ data class UpdatePartnerCommand( val email: String? = null, val phone: String? = null, val active: Boolean? = null, + val ext: Map? = null, ) diff --git a/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt b/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt index 71cf6d6..316ddeb 100644 --- a/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt +++ b/pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt @@ -40,18 +40,18 @@ class PartnerController( @GetMapping fun list(): List = - partnerService.list().map { it.toResponse() } + partnerService.list().map { it.toResponse(partnerService) } @GetMapping("/{id}") fun get(@PathVariable id: UUID): ResponseEntity { val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(partner.toResponse()) + return ResponseEntity.ok(partner.toResponse(partnerService)) } @GetMapping("/by-code/{code}") fun getByCode(@PathVariable code: String): ResponseEntity { val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok(partner.toResponse()) + return ResponseEntity.ok(partner.toResponse(partnerService)) } @PostMapping @@ -67,8 +67,9 @@ class PartnerController( email = request.email, phone = request.phone, active = request.active ?: true, + ext = request.ext, ), - ).toResponse() + ).toResponse(partnerService) @PatchMapping("/{id}") fun update( @@ -85,8 +86,9 @@ class PartnerController( email = request.email, phone = request.phone, active = request.active, + ext = request.ext, ), - ).toResponse() + ).toResponse(partnerService) @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) @@ -106,6 +108,14 @@ data class CreatePartnerRequest( @field:Size(max = 256) val email: String? = null, @field:Size(max = 64) val phone: String? = null, val active: Boolean? = true, + /** + * Custom-field values, validated against [PartnerService.ENTITY_NAME] + * declarations from `metadata__custom_field` (P3.4). Unknown keys + * are rejected with 400; required missing fields are rejected with + * 400; type mismatches are rejected with 400. Omit or send `null` + * to leave the column unchanged. + */ + val ext: Map? = null, ) data class UpdatePartnerRequest( @@ -116,6 +126,13 @@ data class UpdatePartnerRequest( @field:Size(max = 256) val email: String? = null, @field:Size(max = 64) val phone: String? = null, val active: Boolean? = null, + /** + * When present, REPLACES the entire ext map (PATCH semantics for + * the top-level columns, PUT semantics for ext as a whole). Omit + * to leave ext unchanged. Send an empty map to clear it (subject + * to required-field declarations). + */ + val ext: Map? = null, ) data class PartnerResponse( @@ -128,9 +145,10 @@ data class PartnerResponse( val email: String?, val phone: String?, val active: Boolean, + val ext: Map, ) -private fun Partner.toResponse() = PartnerResponse( +private fun Partner.toResponse(service: PartnerService) = PartnerResponse( id = this.id, code = this.code, name = this.name, @@ -140,4 +158,5 @@ private fun Partner.toResponse() = PartnerResponse( email = this.email, phone = this.phone, active = this.active, + ext = service.parseExt(this), ) diff --git a/pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml b/pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml index f2281b2..d9fa189 100644 --- a/pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml +++ b/pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml @@ -55,3 +55,40 @@ menus: icon: users section: Partners order: 300 + +# Custom fields declared on the Partner entity (P3.4 — Tier 1 +# customization). The framework's ExtJsonValidator enforces these on +# every save through the JSONB `ext` column on partners__partner. +# +# These two fields are seeded as `source=core` here so the framework +# has something to demo from a fresh boot. In production, key users +# would add custom fields via the SPA's customization UI (which writes +# `source=user` rows). Plug-ins can also declare custom fields via +# their own YAML. +customFields: + - key: partners_credit_limit + targetEntity: Partner + type: + kind: decimal + precision: 14 + scale: 2 + required: false + pii: false + labelTranslations: + en: Credit limit + zh-CN: 信用额度 + + - key: partners_industry + targetEntity: Partner + type: + kind: enum + allowedValues: + - printing + - publishing + - packaging + - other + required: false + pii: false + labelTranslations: + en: Industry + zh-CN: 行业 diff --git a/pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt b/pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt index b3858fe..646f1a0 100644 --- a/pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt +++ b/pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt @@ -18,6 +18,7 @@ import org.vibeerp.pbc.partners.domain.PartnerType import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository +import org.vibeerp.platform.metadata.customfield.ExtJsonValidator import java.util.Optional import java.util.UUID @@ -26,6 +27,7 @@ class PartnerServiceTest { private lateinit var partners: PartnerJpaRepository private lateinit var addresses: AddressJpaRepository private lateinit var contacts: ContactJpaRepository + private lateinit var extValidator: ExtJsonValidator private lateinit var service: PartnerService @BeforeEach @@ -33,7 +35,11 @@ class PartnerServiceTest { partners = mockk() addresses = mockk() contacts = mockk() - service = PartnerService(partners, addresses, contacts) + extValidator = mockk() + // Default: validator returns the input map unchanged. Tests + // that exercise ext directly override this expectation. + every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } + service = PartnerService(partners, addresses, contacts, extValidator) } @Test diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt index a7fb47b..d3c173f 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/MetadataLoader.kt @@ -151,16 +151,19 @@ class MetadataLoader( entities = files.flatMap { it.parsed.entities }, permissions = files.flatMap { it.parsed.permissions }, menus = files.flatMap { it.parsed.menus }, + customFields = files.flatMap { it.parsed.customFields }, ) wipeBySource(source) insertEntities(source, merged) insertPermissions(source, merged) insertMenus(source, merged) + insertCustomFields(source, merged) log.info( - "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus from {} file(s)", - source, merged.entities.size, merged.permissions.size, merged.menus.size, files.size, + "MetadataLoader: source='{}' loaded {} entities, {} permissions, {} menus, {} custom fields from {} file(s)", + source, merged.entities.size, merged.permissions.size, merged.menus.size, + merged.customFields.size, files.size, ) return LoadResult( @@ -168,6 +171,7 @@ class MetadataLoader( entityCount = merged.entities.size, permissionCount = merged.permissions.size, menuCount = merged.menus.size, + customFieldCount = merged.customFields.size, files = files.map { it.url }, ) } @@ -181,6 +185,7 @@ class MetadataLoader( val entityCount: Int, val permissionCount: Int, val menuCount: Int, + val customFieldCount: Int = 0, val files: List, ) @@ -191,6 +196,7 @@ class MetadataLoader( jdbc.update("DELETE FROM metadata__entity WHERE source = :source", params) jdbc.update("DELETE FROM metadata__permission WHERE source = :source", params) jdbc.update("DELETE FROM metadata__menu WHERE source = :source", params) + jdbc.update("DELETE FROM metadata__custom_field WHERE source = :source", params) } private fun insertEntities(source: String, file: MetadataYamlFile) { @@ -244,6 +250,23 @@ class MetadataLoader( } } + private fun insertCustomFields(source: String, file: MetadataYamlFile) { + val now = Timestamp.from(Instant.now()) + for (customField in file.customFields) { + jdbc.update( + """ + INSERT INTO metadata__custom_field (id, source, payload, created_at, updated_at) + VALUES (:id, :source, CAST(:payload AS jsonb), :now, :now) + """.trimIndent(), + MapSqlParameterSource() + .addValue("id", UUID.randomUUID()) + .addValue("source", source) + .addValue("payload", jsonMapper.writeValueAsString(customField)) + .addValue("now", now), + ) + } + } + private data class ParsedYaml( val url: String, val parsed: MetadataYamlFile, diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/CustomFieldRegistry.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/CustomFieldRegistry.kt new file mode 100644 index 0000000..8e4b673 --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/CustomFieldRegistry.kt @@ -0,0 +1,159 @@ +package org.vibeerp.platform.metadata.customfield + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import org.slf4j.LoggerFactory +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.entity.CustomField +import org.vibeerp.api.v1.entity.FieldType +import org.vibeerp.platform.metadata.yaml.CustomFieldTypeYaml +import org.vibeerp.platform.metadata.yaml.CustomFieldYaml +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory index of every [CustomField] declaration loaded from + * `metadata__custom_field` rows, grouped by [CustomField.targetEntity]. + * + * **Why an in-memory cache.** Custom-field declarations are read on + * EVERY entity save (the [ExtJsonValidator] calls [forEntity]) and on + * every form render. Hitting Postgres for each call would dominate + * the request latency budget for what is otherwise a sub-millisecond + * operation, and the data is updated rarely (boot, plug-in load, key + * user clicks "save" in the form designer). The cache is rebuilt by + * [refresh], which the metadata loader calls after every successful + * load + the customization UI will call after each user-initiated + * change. + * + * **What this is NOT.** This is not a generic metadata cache. It only + * indexes custom fields, because they have a uniquely high call rate + * and a unique consumption pattern (look up by entity name, return a + * list). Other metadata kinds (entities, permissions, menus) are + * served directly by the [org.vibeerp.platform.metadata.web.MetadataController] + * read-through to the database — request rates for those are low and + * an in-memory cache adds complexity for no payoff. + * + * **Concurrency.** [ConcurrentHashMap] under the hood; reads are + * lock-free, writes during [refresh] are atomic per-entity. The race + * window is intentionally tolerated: a save in flight when a refresh + * happens may see either the old field set or the new one. Both are + * valid framework states and the user-visible outcome is "your save + * went through, the new field is now also enforced". + */ +@Component +class CustomFieldRegistry( + private val jdbc: NamedParameterJdbcTemplate, +) { + + private val log = LoggerFactory.getLogger(CustomFieldRegistry::class.java) + + private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() + + private val byEntity: ConcurrentHashMap> = ConcurrentHashMap() + + /** + * Return the declared custom fields for [entityName], in the order + * they appear in the database. Returns an empty list (NOT null) + * when no fields are declared, so callers can iterate without a + * null check. + */ + fun forEntity(entityName: String): List = + byEntity[entityName] ?: emptyList() + + /** + * Snapshot of every entity name that currently has at least one + * declared custom field. Used by the public metadata REST endpoint + * and by the unit tests; not on the hot path. + */ + fun entitiesWithCustomFields(): Set = byEntity.keys.toSet() + + /** + * Read every `metadata__custom_field` row from the database and + * rebuild the in-memory index. Called at startup (after the + * MetadataLoader has loaded core YAML) and after every plug-in + * load. Tolerates rows with malformed payloads — they're logged + * and skipped, the rest of the load continues. + */ + fun refresh() { + val rows = jdbc.query( + "SELECT payload FROM metadata__custom_field", + emptyMap(), + ) { rs, _ -> rs.getString("payload") } + + val parsed = mutableListOf() + var malformed = 0 + for (payload in rows) { + val cf = try { + val yaml = jsonMapper.readValue(payload, CustomFieldYaml::class.java) + yaml.toApiV1() + } catch (ex: Throwable) { + malformed++ + log.warn("CustomFieldRegistry: skipping malformed metadata__custom_field row: {}", ex.message) + null + } + if (cf != null) parsed += cf + } + + val grouped = parsed.groupBy { it.targetEntity } + // Atomic-ish swap: clear-then-put under the same operation. The + // window where a reader might see "the old + the new" simultaneously + // is acceptable — see the class doc on concurrency. + byEntity.clear() + byEntity.putAll(grouped) + + log.info( + "CustomFieldRegistry: refreshed {} custom fields across {} entities ({} malformed rows skipped)", + parsed.size, grouped.size, malformed, + ) + } +} + +/** + * Convert the YAML wire format to the api.v1 runtime type. + * + * The conversion is non-trivial because [FieldType] is a sealed + * interface with per-variant config — STRING has maxLength, DECIMAL + * has precision/scale, ENUM has allowedValues, REFERENCE has + * targetEntity. The YAML has all of those as optional fields and + * picks the right ones based on `kind`. + * + * Throws [IllegalArgumentException] when the YAML is internally + * inconsistent (e.g. `kind: enum` with no `allowedValues`). Callers + * (the registry refresh) catch this and skip the malformed row. + */ +internal fun CustomFieldYaml.toApiV1(): CustomField = + CustomField( + key = key, + targetEntity = targetEntity, + type = type.toFieldType(), + required = required, + pii = pii, + labelTranslations = labelTranslations, + ) + +internal fun CustomFieldTypeYaml.toFieldType(): FieldType = when (kind.lowercase()) { + "string" -> FieldType.String(maxLength = maxLength ?: 255) + "integer" -> FieldType.Integer + "decimal" -> FieldType.Decimal( + precision = precision ?: 19, + scale = scale ?: 4, + ) + "boolean" -> FieldType.Boolean + "date" -> FieldType.Date + "date_time", "datetime" -> FieldType.DateTime + "uuid" -> FieldType.Uuid + "money" -> FieldType.Money + "quantity" -> FieldType.Quantity + "reference" -> FieldType.Reference( + targetEntity = requireNotNull(targetEntity) { + "REFERENCE custom field requires 'targetEntity'" + }, + ) + "enum" -> FieldType.Enum( + allowedValues = requireNotNull(allowedValues) { + "ENUM custom field requires 'allowedValues'" + }, + ) + "json" -> FieldType.Json + else -> throw IllegalArgumentException("Unknown custom field kind: '$kind'") +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt new file mode 100644 index 0000000..cd0634d --- /dev/null +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt @@ -0,0 +1,230 @@ +package org.vibeerp.platform.metadata.customfield + +import org.springframework.stereotype.Component +import org.vibeerp.api.v1.entity.CustomField +import org.vibeerp.api.v1.entity.FieldType +import java.math.BigDecimal +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.format.DateTimeParseException +import java.util.UUID + +/** + * Validates the contents of a JSONB `ext` map against the declared + * [CustomField]s for an entity. This is the **on-save** half of the + * Tier 1 customization story (P3.4) — the metadata declarations are + * loaded by [CustomFieldRegistry], and every PBC service that writes + * the `ext` column calls [validate] before persisting. + * + * **What this enforces**: + * + * 1. **Unknown keys are rejected.** No schemaless writes — if a + * caller sends `ext.foo` and `foo` is not declared as a custom + * field on this entity, the request is rejected. This is the rule + * that makes the form designer, the OpenAPI generator, and the AI + * agent function catalog trustworthy: every value in `ext` MUST + * match a declared field. + * 2. **Required fields must be present.** A field declared + * `required: true` whose value is missing or `null` is a violation. + * 3. **Type coercion.** Each [FieldType] variant has its own coercion + * rule. Numbers can come in as `Int`, `Long`, `Double`, or string + * (since some JSON parsers preserve scale by quoting). Booleans + * accept `true`/`false`. Dates accept ISO-8601 strings. Enums + * accept any of the declared `allowedValues` exactly. + * + * **What this does NOT enforce** (deliberately): + * + * - Permission checks (P4.3 — "can THIS user write to THIS field"). + * - PII tagging (the field's `pii` flag is read by DSAR jobs, not + * by save-time validation). + * - Reference target existence (P3.4 doesn't load every referenced + * entity at save time; that requires the EntityRegistry to be + * cross-PBC, which is a separate seam landing later). + * + * **Failure mode.** [validate] either returns the canonicalised map + * (every value coerced to its native Kotlin type for clean JSON + * encoding) or throws [IllegalArgumentException] with ALL violations + * concatenated. The exception is caught by the framework's + * `GlobalExceptionHandler` and returned to the caller as a 400 Bad + * Request. Returning all violations at once (instead of failing on + * the first one) lets a form submitter fix every error in one + * round-trip. + */ +@Component +class ExtJsonValidator( + private val registry: CustomFieldRegistry, +) { + + /** + * Validate (and canonicalise) the [ext] map for [entityName]. + * + * @return a fresh map containing only the declared fields, with + * values coerced to their native Kotlin types — safe to encode + * directly as JSON. + * @throws IllegalArgumentException with a single line per + * violation, joined by `; `, when validation fails. + */ + fun validate(entityName: String, ext: Map?): Map { + val declared: List = registry.forEntity(entityName) + val incoming: Map = ext ?: emptyMap() + + // Fast path: no declared fields → must be empty. + if (declared.isEmpty()) { + if (incoming.isEmpty()) return emptyMap() + throw IllegalArgumentException( + "ext must be empty for entity '$entityName' (no custom fields declared); got keys: ${incoming.keys}", + ) + } + + val declaredByKey: Map = declared.associateBy { it.key } + val violations = mutableListOf() + val canonical = LinkedHashMap() + + // Reject unknown keys. + val unknown = incoming.keys - declaredByKey.keys + if (unknown.isNotEmpty()) { + violations += "ext contains undeclared key(s) for '$entityName': $unknown" + } + + // Validate each declared field. + for (field in declared) { + val raw = incoming[field.key] + if (raw == null) { + if (field.required) { + violations += "ext.${field.key} is required" + } + // null is a legitimate "field not provided" — drop from canonical. + continue + } + val coerced = try { + coerceValue(field, raw) + } catch (ex: ExtFieldViolation) { + violations += "ext.${field.key}: ${ex.message}" + null + } + if (coerced != null) { + canonical[field.key] = coerced + } + } + + if (violations.isNotEmpty()) { + throw IllegalArgumentException(violations.joinToString("; ")) + } + return canonical + } + + /** + * Coerce one [raw] value for a single [field] to its native type. + * + * Throws [ExtFieldViolation] (an internal type) on a coercion + * error so [validate] can collect the message into the + * per-call violations list. + */ + private fun coerceValue(field: CustomField, raw: Any): Any = when (val type = field.type) { + is FieldType.String -> { + val s = raw.toString() + if (s.length > type.maxLength) { + throw ExtFieldViolation("string exceeds maxLength ${type.maxLength} (got ${s.length})") + } + s + } + + FieldType.Integer -> when (raw) { + is Number -> raw.toLong() + is String -> raw.toLongOrNull() + ?: throw ExtFieldViolation("not a valid integer: '$raw'") + else -> throw ExtFieldViolation("not a valid integer: $raw") + } + + is FieldType.Decimal -> { + val bd = when (raw) { + is BigDecimal -> raw + is Number -> BigDecimal(raw.toString()) + is String -> try { + BigDecimal(raw) + } catch (ex: NumberFormatException) { + throw ExtFieldViolation("not a valid decimal: '$raw'") + } + else -> throw ExtFieldViolation("not a valid decimal: $raw") + } + // precision = total significant digits; scale = digits after the point. + // bd.precision() reports the unscaled significand digit count. + if (bd.scale() > type.scale) { + throw ExtFieldViolation("decimal scale ${bd.scale()} exceeds declared scale ${type.scale}") + } + // Effective precision after normalising trailing zeros. + val effectivePrecision = bd.stripTrailingZeros().let { + // BigDecimal("0").precision() reports 1 — fine for our check. + if (it.scale() < 0) it.precision() - it.scale() else it.precision() + } + if (effectivePrecision > type.precision) { + throw ExtFieldViolation( + "decimal precision $effectivePrecision exceeds declared precision ${type.precision}", + ) + } + // Return as a plain string so JSON encoding preserves scale. + bd.toPlainString() + } + + FieldType.Boolean -> when (raw) { + is Boolean -> raw + is String -> when (raw.lowercase()) { + "true" -> true + "false" -> false + else -> throw ExtFieldViolation("not a valid boolean: '$raw'") + } + else -> throw ExtFieldViolation("not a valid boolean: $raw") + } + + FieldType.Date -> try { + LocalDate.parse(raw.toString()).toString() + } catch (ex: DateTimeParseException) { + throw ExtFieldViolation("not a valid ISO-8601 date: '$raw'") + } + + FieldType.DateTime -> try { + // Accept any ISO-8601 offset date-time and canonicalise to UTC. + OffsetDateTime.parse(raw.toString()).toString() + } catch (ex: DateTimeParseException) { + throw ExtFieldViolation("not a valid ISO-8601 date-time: '$raw'") + } + + FieldType.Uuid -> try { + UUID.fromString(raw.toString()).toString() + } catch (ex: IllegalArgumentException) { + throw ExtFieldViolation("not a valid UUID: '$raw'") + } + + is FieldType.Enum -> { + val s = raw.toString() + if (s !in type.allowedValues) { + throw ExtFieldViolation("value '$s' is not in allowed set ${type.allowedValues}") + } + s + } + + FieldType.Money, + FieldType.Quantity, + FieldType.Json, + is FieldType.Reference, + -> { + // These types pass through as-is in v1 — they're either + // structured values the API surface decides on (Money, + // Quantity), opaque blobs (Json), or pointers whose + // existence the validator doesn't yet check (Reference, + // pending the cross-PBC EntityRegistry seam). The framework + // still ensures the value is non-null because the + // null-check happens above this method. + raw + } + } + + /** + * Internal sentinel that lets [coerceValue] surface a per-field + * message that [validate] turns into one entry in the violations + * list. Not exported because callers see the violations as + * [IllegalArgumentException] message strings, not typed + * exceptions per field. + */ + private class ExtFieldViolation(message: String) : RuntimeException(message) +} diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt index 156ba7e..e980c82 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/web/MetadataController.kt @@ -3,8 +3,12 @@ package org.vibeerp.platform.metadata.web import com.fasterxml.jackson.databind.ObjectMapper import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.vibeerp.api.v1.entity.CustomField +import org.vibeerp.api.v1.entity.FieldType +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry /** * Read-only REST endpoint that returns the seeded metadata. @@ -27,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController class MetadataController( private val jdbc: NamedParameterJdbcTemplate, private val objectMapper: ObjectMapper, + private val customFieldRegistry: CustomFieldRegistry, ) { @GetMapping @@ -34,6 +39,7 @@ class MetadataController( "entities" to readPayloads("metadata__entity"), "permissions" to readPayloads("metadata__permission"), "menus" to readPayloads("metadata__menu"), + "customFields" to readPayloads("metadata__custom_field"), ) @GetMapping("/entities") @@ -45,6 +51,52 @@ class MetadataController( @GetMapping("/menus") fun menus(): List> = readPayloads("metadata__menu") + /** + * Read every custom-field declaration as raw `metadata__custom_field` + * payload rows. Returns the YAML wire format unchanged so the SPA + * (or any introspection tool) gets the same shape it would get + * from reading the source YAML directly. + */ + @GetMapping("/custom-fields") + fun customFieldsAll(): List> = readPayloads("metadata__custom_field") + + /** + * Per-entity custom-field view, served from the in-memory + * [CustomFieldRegistry] (so it reflects every refresh, including + * after plug-in load) rather than re-querying the database. The + * shape is the api.v1 [CustomField] runtime view, not the YAML + * wire format — this endpoint is consumed by the SPA's form + * builder which wants resolved [FieldType] descriptors, not the + * raw `kind: decimal` discriminator. + */ + @GetMapping("/custom-fields/{entityName}") + fun customFieldsForEntity(@PathVariable entityName: String): List> = + customFieldRegistry.forEntity(entityName).map { it.toApiResponse() } + + private fun CustomField.toApiResponse(): Map = mapOf( + "key" to key, + "targetEntity" to targetEntity, + "type" to type.toApiResponse(), + "required" to required, + "pii" to pii, + "labelTranslations" to labelTranslations, + ) + + private fun FieldType.toApiResponse(): Map = when (this) { + is FieldType.String -> mapOf("kind" to "string", "maxLength" to maxLength) + FieldType.Integer -> mapOf("kind" to "integer") + is FieldType.Decimal -> mapOf("kind" to "decimal", "precision" to precision, "scale" to scale) + FieldType.Boolean -> mapOf("kind" to "boolean") + FieldType.Date -> mapOf("kind" to "date") + FieldType.DateTime -> mapOf("kind" to "dateTime") + FieldType.Uuid -> mapOf("kind" to "uuid") + FieldType.Money -> mapOf("kind" to "money") + FieldType.Quantity -> mapOf("kind" to "quantity") + is FieldType.Reference -> mapOf("kind" to "reference", "targetEntity" to targetEntity) + is FieldType.Enum -> mapOf("kind" to "enum", "allowedValues" to allowedValues) + FieldType.Json -> mapOf("kind" to "json") + } + private fun readPayloads(table: String): List> { return jdbc.query( "SELECT source, payload FROM $table ORDER BY source", diff --git a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt index 036dd8e..acbd28b 100644 --- a/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt +++ b/platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/yaml/MetadataYaml.kt @@ -31,6 +31,7 @@ data class MetadataYamlFile( val entities: List = emptyList(), val permissions: List = emptyList(), val menus: List = emptyList(), + val customFields: List = emptyList(), ) /** @@ -117,3 +118,89 @@ data class MenuYaml( val section: String = "Other", val order: Int = 1000, ) + +/** + * A custom field bolted onto an existing entity. + * + * Custom fields are how the framework satisfies guardrail #1 ("core + * stays domain-agnostic") and the Tier 1 customization story: a + * printing shop that needs a `credit_limit` column on Partner does + * not patch `pbc-partners`. They declare a custom field — through the + * SPA editor (Tier 1) or through a plug-in YAML (Tier 2) — and the + * value lands in the JSONB `ext` column on the host entity's table the + * same instant. The framework's [ExtJsonValidator] enforces the + * declared types on every save; the SPA's form builder, the OpenAPI + * generator, the AI agent function catalog, and the future PII/DSAR + * pipeline all read from these declarations. + * + * Why YAML mirrors `org.vibeerp.api.v1.entity.CustomField` instead of + * being that type directly: + * - The api.v1 type is the **runtime** view returned by + * [org.vibeerp.api.v1.entity.EntityRegistry] — it ships with no + * Jackson annotations because api.v1 must stay free of host + * dependencies. + * - This [CustomFieldYaml] is the **wire format** parsed from the + * file. The loader builds the runtime [CustomField] from it. The + * duplication is real but each side stays minimal. + * + * @property key Stable identifier within [targetEntity]. Convention: + * `_` in snake_case (e.g. + * `partners_credit_limit`, `printingshop_plate_thickness_mm`). + * The prefix prevents two unrelated declarations from colliding on + * a generic key like `notes`. + * @property targetEntity The entity name (matches `EntityYaml.name`) + * this field attaches to. The validator looks the field up by this + * name when an entity row is saved. + * @property type The field type. See [CustomFieldTypeYaml] — the YAML + * shape closely mirrors `org.vibeerp.api.v1.entity.FieldType` but + * in a Jackson-friendly form. + * @property required Whether the value must be present on insert/update. + * @property pii Whether this field contains personally identifiable + * information. Drives DSAR exports and erasure jobs (architecture + * spec section 8 — "Data sovereignty"). When in doubt, mark `true`. + * @property labelTranslations Locale code → human-readable label. + * Optional; the SPA's form builder shows the key when no label is + * provided. Plug-ins SHOULD ship at least the platform default + * locale here. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class CustomFieldYaml( + val key: String, + val targetEntity: String, + val type: CustomFieldTypeYaml, + val required: Boolean = false, + val pii: Boolean = false, + val labelTranslations: Map = emptyMap(), +) + +/** + * Wire format for a custom field's type. Each property carries the + * config a single FieldType variant needs; only the field that + * matches `kind` is consulted, the rest are ignored. + * + * Why a flat shape with a discriminator instead of a Jackson polymorphic + * type hierarchy: + * - YAML files are written by hand by plug-in authors and key users. + * A flat `kind: enum` + `allowedValues: [...]` block is far more + * readable than `type: { type: enum, allowedValues: [...] }`. + * - Jackson's polymorphic deserialization needs `@JsonTypeInfo` + * annotations on every variant, which would force api.v1's + * [org.vibeerp.api.v1.entity.FieldType] to import Jackson — + * forbidden by guardrail #10 ("api.v1 is the only stable + * contract"). + * + * Supported `kind` values match the closed set in + * [org.vibeerp.api.v1.entity.FieldType]: `string`, `integer`, + * `decimal`, `boolean`, `date`, `date_time`, `uuid`, `money`, + * `quantity`, `reference`, `enum`, `json`. Unknown kinds are rejected + * at load time. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class CustomFieldTypeYaml( + val kind: String, + val maxLength: Int? = null, // string + val precision: Int? = null, // decimal + val scale: Int? = null, // decimal + val targetEntity: String? = null, // reference + val allowedValues: List? = null, // enum +) diff --git a/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt new file mode 100644 index 0000000..61b4884 --- /dev/null +++ b/platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt @@ -0,0 +1,176 @@ +package org.vibeerp.platform.metadata.customfield + +import assertk.all +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.messageContains +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.vibeerp.api.v1.entity.CustomField +import org.vibeerp.api.v1.entity.FieldType + +class ExtJsonValidatorTest { + + private lateinit var registry: CustomFieldRegistry + private lateinit var validator: ExtJsonValidator + + @BeforeEach + fun setUp() { + registry = mockk() + validator = ExtJsonValidator(registry) + } + + private fun stub(vararg fields: CustomField) { + every { registry.forEntity(ENTITY) } returns fields.toList() + } + + @Test + fun `entity with no declared custom fields rejects any non-empty ext`() { + stub() // empty + assertThat(validator.validate(ENTITY, null)).isEmpty() + assertThat(validator.validate(ENTITY, emptyMap())).isEmpty() + + val failure = assertFailure { validator.validate(ENTITY, mapOf("anything" to 1)) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("ext must be empty") + } + + @Test + fun `unknown ext key is rejected`() { + stub(CustomField("known", ENTITY, FieldType.Integer)) + + val failure = assertFailure { + validator.validate(ENTITY, mapOf("known" to 1, "rogue" to "x")) + } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("undeclared key") + } + + @Test + fun `required missing field is rejected`() { + stub(CustomField("must_have", ENTITY, FieldType.Integer, required = true)) + + val failure = assertFailure { validator.validate(ENTITY, emptyMap()) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("must_have is required") + } + + @Test + fun `string respects maxLength`() { + stub(CustomField("nm", ENTITY, FieldType.String(maxLength = 5))) + + assertThat(validator.validate(ENTITY, mapOf("nm" to "okay"))).isEqualTo(mapOf("nm" to "okay")) + + val failure = assertFailure { validator.validate(ENTITY, mapOf("nm" to "way too long")) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("maxLength 5") + } + + @Test + fun `integer accepts Number and numeric String, rejects garbage`() { + stub(CustomField("n", ENTITY, FieldType.Integer)) + + assertThat(validator.validate(ENTITY, mapOf("n" to 42))).isEqualTo(mapOf("n" to 42L)) + assertThat(validator.validate(ENTITY, mapOf("n" to "42"))).isEqualTo(mapOf("n" to 42L)) + + val failure = assertFailure { validator.validate(ENTITY, mapOf("n" to "not a number")) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("not a valid integer") + } + + @Test + fun `decimal enforces declared scale`() { + stub(CustomField("limit", ENTITY, FieldType.Decimal(precision = 10, scale = 2))) + + // Two-place decimal accepted, value canonicalised to plain string. + assertThat(validator.validate(ENTITY, mapOf("limit" to "1234.56"))) + .isEqualTo(mapOf("limit" to "1234.56")) + + // Three-place decimal exceeds scale. + val failure = assertFailure { validator.validate(ENTITY, mapOf("limit" to "1234.567")) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("scale 3 exceeds") + } + + @Test + fun `boolean accepts Boolean and 'true'-or-'false' string`() { + stub(CustomField("flag", ENTITY, FieldType.Boolean)) + + assertThat(validator.validate(ENTITY, mapOf("flag" to true))).isEqualTo(mapOf("flag" to true)) + assertThat(validator.validate(ENTITY, mapOf("flag" to "false"))).isEqualTo(mapOf("flag" to false)) + + val failure = assertFailure { validator.validate(ENTITY, mapOf("flag" to "maybe")) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("not a valid boolean") + } + + @Test + fun `date accepts ISO-8601 string`() { + stub(CustomField("d", ENTITY, FieldType.Date)) + + assertThat(validator.validate(ENTITY, mapOf("d" to "2026-04-08"))) + .isEqualTo(mapOf("d" to "2026-04-08")) + + val failure = assertFailure { validator.validate(ENTITY, mapOf("d" to "08/04/2026")) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("ISO-8601 date") + } + + @Test + fun `enum accepts allowed value, rejects others`() { + stub( + CustomField( + "kind", + ENTITY, + FieldType.Enum(allowedValues = listOf("a", "b", "c")), + ), + ) + + assertThat(validator.validate(ENTITY, mapOf("kind" to "a"))).isEqualTo(mapOf("kind" to "a")) + + val failure = assertFailure { validator.validate(ENTITY, mapOf("kind" to "z")) } + failure.isInstanceOf(IllegalArgumentException::class) + failure.messageContains("not in allowed set") + } + + @Test + fun `multiple violations are returned together`() { + stub( + CustomField("required_int", ENTITY, FieldType.Integer, required = true), + CustomField("limited_str", ENTITY, FieldType.String(maxLength = 3)), + ) + + val failure = assertFailure { + validator.validate( + ENTITY, + mapOf("limited_str" to "too long", "rogue" to 1), + ) + } + failure.isInstanceOf(IllegalArgumentException::class) + failure.all { + messageContains("undeclared key") + messageContains("required_int is required") + messageContains("maxLength 3") + } + } + + @Test + fun `null value for non-required field is dropped from canonical map`() { + stub(CustomField("opt", ENTITY, FieldType.Integer)) + + // Sending {opt: null} is identical to omitting opt entirely. + val canonical = validator.validate(ENTITY, mapOf("opt" to null)) + assertThat(canonical.keys).hasSize(0) + } + + companion object { + const val ENTITY: String = "TestEntity" + } +} diff --git a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt index 65d0f14..f1abd8c 100644 --- a/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt +++ b/platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt @@ -12,6 +12,7 @@ import org.vibeerp.api.v1.i18n.LocaleProvider import org.vibeerp.api.v1.plugin.PluginJdbc import org.vibeerp.platform.i18n.IcuTranslator import org.vibeerp.platform.metadata.MetadataLoader +import org.vibeerp.platform.metadata.customfield.CustomFieldRegistry import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar import org.vibeerp.platform.plugins.lint.PluginLinter @@ -60,6 +61,7 @@ class VibeErpPluginManager( private val linter: PluginLinter, private val liquibaseRunner: PluginLiquibaseRunner, private val metadataLoader: MetadataLoader, + private val customFieldRegistry: CustomFieldRegistry, private val localeProvider: LocaleProvider, ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { @@ -85,6 +87,18 @@ class VibeErpPluginManager( ) } + // Refresh the in-memory custom-field index now so PBC services + // can validate `ext` columns even if no plug-ins are loaded. + // The same refresh happens again after each plug-in load below. + try { + customFieldRegistry.refresh() + } catch (ex: Throwable) { + log.error( + "VibeErpPluginManager: CustomFieldRegistry initial refresh failed; ext validation will allow nothing", + ex, + ) + } + if (!properties.autoLoad) { log.info("vibe_erp plug-in auto-load disabled — skipping plug-in scan") return @@ -170,6 +184,18 @@ class VibeErpPluginManager( } } + // Refresh the custom-field index now that every plug-in has + // contributed its declarations. The single refresh covers all + // plug-ins at once instead of one refresh per plug-in. + try { + customFieldRegistry.refresh() + } catch (ex: Throwable) { + log.warn( + "VibeErpPluginManager: CustomFieldRegistry post-plug-in refresh failed; the in-memory index may be stale", + ex, + ) + } + startPlugins() // PF4J's `startPlugins()` calls each plug-in's PF4J `start()`