Commit 986f02ce64aaa7eb5db436ef8023643f9a27bd2d
1 parent
9106d9ac
refactor(metadata): HasExt marker + applyTo/parseExt helpers on ExtJsonValidator
Removes the ext-handling copy/paste that had grown across four PBCs
(partners, inventory, orders-sales, orders-purchase). Every service
that wrote the JSONB `ext` column was manually doing the same
four-step sequence: validate, null-check, serialize with a local
ObjectMapper, assign to the entity. And every response mapper was
doing the inverse: check-if-blank, parse, cast, swallow errors.
Net: ~15 lines saved per PBC, one place to change the ext contract
later (e.g. PII redaction, audit tagging, field-level events), and
a stable plug-in opt-in mechanism — any plug-in entity that
implements `HasExt` automatically participates.
New api.v1 surface:
interface HasExt {
val extEntityName: String // key into metadata__custom_field
var ext: String // the serialized JSONB column
}
Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own
entities into the same validation path. Zero Spring/Jackson
dependencies — api.v1 stays clean.
Extended `ExtJsonValidator` (platform-metadata) with two helpers:
fun applyTo(entity: HasExt, ext: Map<String, Any?>?)
— null-safe; validates; writes canonical JSON to entity.ext.
Replaces the validate + writeValueAsString + assign triplet
in every service's create() and update().
fun parseExt(entity: HasExt): Map<String, Any?>
— returns empty map on blank/corrupt column; response
mappers never 500 on bad data. Replaces the four identical
parseExt local functions.
ExtJsonValidator now takes an ObjectMapper via constructor
injection (Spring Boot's auto-configured bean).
Entities that now implement HasExt (override val extEntityName;
override var ext; companion object const val ENTITY_NAME):
- Partner (`partners.Partner` → "Partner")
- Location (`inventory.Location` → "Location")
- SalesOrder (`orders_sales.SalesOrder` → "SalesOrder")
- PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder")
Deliberately NOT converted this chunk:
- WorkOrder (pbc-production) — its ext column has no declared
fields yet; a follow-up that adds declarations AND the
HasExt implementation is cleaner than splitting the two.
- JournalEntry (pbc-finance) — derived state, no ext column.
Services lose:
- The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()`
field (four copies eliminated)
- The `parseExt(entity): Map` helper function (four copies)
- The `companion object { const val ENTITY_NAME = ... }` constant
(moved onto the entity where it belongs)
- The `val canonicalExt = extValidator.validate(...)` +
`.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }`
create pattern (replaced with one applyTo call)
- The `if (command.ext != null) { ... }` update pattern
(applyTo is null-safe)
Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and
parseExt (null-safe path, happy path, failure path, blank column,
round-trip, malformed JSON). Existing service tests just swap the
mock setup from stubbing `validate` to stubbing `applyTo` and
`parseExt` with no-ops.
Smoke verified end-to-end against real Postgres:
- POST /partners with valid ext (partners_credit_limit,
partners_industry) → 201, canonical form persisted.
- GET /partners/by-code/X → 200, ext round-trips.
- POST with invalid enum value → 400 "value 'x' is not in
allowed set [printing, publishing, packaging, other]".
- POST with undeclared key → 400 "ext contains undeclared
key(s) for 'Partner': [rogue_field]".
- PATCH with new ext → 200, ext updated.
- PATCH WITHOUT ext field → 200, prior ext preserved (null-safe
applyTo).
- POST /orders/sales-orders with no ext → 201, the create path
via the shared helper still works.
246 unit tests (+6 over 240), 18 Gradle subprojects.
Showing
17 changed files
with
307 additions
and
141 deletions
CLAUDE.md
| ... | ... | @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only |
| 96 | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | |
| 98 | 98 | - **18 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`. |
| 99 | -- **240 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build. | |
| 99 | +- **246 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build. | |
| 100 | 100 | - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), 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. |
| 101 | 101 | - **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade. |
| 102 | 102 | - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events. | ... | ... |
PROGRESS.md
| ... | ... | @@ -10,11 +10,11 @@ |
| 10 | 10 | |
| 11 | 11 | | | | |
| 12 | 12 | |---|---| |
| 13 | -| **Latest version** | v0.19.0 (pbc-production v2 — IN_PROGRESS + BOM + scrap) | | |
| 14 | -| **Latest commit** | `75a75ba feat(production): P5.7 v2 — IN_PROGRESS state + BOM auto-issue + scrap flow` | | |
| 13 | +| **Latest version** | v0.19.1 (HasExt marker interface + ExtJsonValidator helpers) | | |
| 14 | +| **Latest commit** | `TBD refactor(metadata): HasExt marker + applyTo/parseExt helpers on ExtJsonValidator` | | |
| 15 | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | 16 | | **Modules** | 18 | |
| 17 | -| **Unit tests** | 240, all green | | |
| 17 | +| **Unit tests** | 246, all green | | |
| 18 | 18 | | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | |
| 19 | 19 | | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | |
| 20 | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/HasExt.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.entity | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Marker interface for every entity that participates in Tier 1 | |
| 5 | + * customization — i.e. any entity with a JSONB `ext` column that | |
| 6 | + * must be validated against [CustomField] declarations on save and | |
| 7 | + * decoded for response DTOs on read. | |
| 8 | + * | |
| 9 | + * **Why this is in api.v1, not platform.metadata:** plug-ins define | |
| 10 | + * their own entities and need to opt into the same validation and | |
| 11 | + * serialization path as the framework's core PBCs. Keeping the | |
| 12 | + * marker in api.v1 means a plug-in's `Plate` or `InkRecipe` can | |
| 13 | + * implement `HasExt` and automatically get: | |
| 14 | + * - validation against its `metadata__custom_field` declarations, | |
| 15 | + * - canonical JSON serialization into the column, | |
| 16 | + * - safe parse-back into a `Map<String, Any?>` for response DTOs, | |
| 17 | + * all through the same `ExtJsonValidator.applyTo(...)` / | |
| 18 | + * `.parseExt(...)` helpers that the core PBCs use. No plug-in code | |
| 19 | + * should need to reach into `platform.metadata.internal.*` for ext | |
| 20 | + * handling. | |
| 21 | + * | |
| 22 | + * **Interface surface is deliberately tiny** — two members. | |
| 23 | + * | |
| 24 | + * - [extEntityName] is the stable string key used to look up | |
| 25 | + * declared fields in the custom field registry. The convention is | |
| 26 | + * `<pbc>.<Entity>` (e.g. `"partners.Partner"`, `"inventory.Location"`). | |
| 27 | + * Plug-ins are expected to namespace theirs under the plug-in id | |
| 28 | + * (e.g. `"printing-shop.Plate"`). | |
| 29 | + * - [ext] is the serialized JSONB column. Entities should back this | |
| 30 | + * with a `@Column(columnDefinition = "jsonb") var ext: String = | |
| 31 | + * "{}"`. The `ExtJsonValidator.applyTo(...)` helper writes the | |
| 32 | + * canonical form here; `ExtJsonValidator.parseExt(...)` reads it | |
| 33 | + * back. | |
| 34 | + * | |
| 35 | + * **Why `ext` is a `String`, not a `Map`:** Hibernate writes the JSON | |
| 36 | + * column as a string (or its `@JdbcTypeCode(SqlTypes.JSON)` wrapper | |
| 37 | + * — effectively the same thing). Exposing it as a Map here would | |
| 38 | + * force plug-ins to depend on Jackson, which `api.v1` explicitly | |
| 39 | + * does NOT. Keeping it a String means the only Jackson touchpoint | |
| 40 | + * lives inside `ExtJsonValidator`, which is already in | |
| 41 | + * `platform.metadata`. | |
| 42 | + * | |
| 43 | + * **Does NOT extend [Entity].** Not every Entity has custom fields; | |
| 44 | + * not every HasExt needs an Id (though in practice they always do). | |
| 45 | + * Keeping the two marker interfaces orthogonal means a plug-in can | |
| 46 | + * choose which behaviors it opts into without inheritance bloat. | |
| 47 | + */ | |
| 48 | +public interface HasExt { | |
| 49 | + /** | |
| 50 | + * The stable entity-name key under which this entity's custom | |
| 51 | + * fields are declared in `metadata__custom_field`. Convention: | |
| 52 | + * `<pbc>.<Entity>` for core; `<plugin-id>.<Entity>` for plug-ins. | |
| 53 | + */ | |
| 54 | + public val extEntityName: String | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * The serialized JSONB column. Managed by | |
| 58 | + * `ExtJsonValidator.applyTo(...)` on save and decoded by | |
| 59 | + * `ExtJsonValidator.parseExt(...)` on read. Should default to | |
| 60 | + * `"{}"` on the entity class so an un-customized row still | |
| 61 | + * round-trips through JSON cleanly. | |
| 62 | + */ | |
| 63 | + public var ext: String | |
| 64 | +} | ... | ... |
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt
| 1 | 1 | package org.vibeerp.pbc.inventory.application |
| 2 | 2 | |
| 3 | -import com.fasterxml.jackson.databind.ObjectMapper | |
| 4 | -import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 5 | 3 | import org.springframework.stereotype.Service |
| 6 | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | 5 | import org.vibeerp.pbc.inventory.domain.Location |
| ... | ... | @@ -32,8 +30,6 @@ class LocationService( |
| 32 | 30 | private val extValidator: ExtJsonValidator, |
| 33 | 31 | ) { |
| 34 | 32 | |
| 35 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | |
| 36 | - | |
| 37 | 33 | @Transactional(readOnly = true) |
| 38 | 34 | fun list(): List<Location> = locations.findAll() |
| 39 | 35 | |
| ... | ... | @@ -47,17 +43,14 @@ class LocationService( |
| 47 | 43 | require(!locations.existsByCode(command.code)) { |
| 48 | 44 | "location code '${command.code}' is already taken" |
| 49 | 45 | } |
| 50 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | |
| 51 | - return locations.save( | |
| 52 | - Location( | |
| 53 | - code = command.code, | |
| 54 | - name = command.name, | |
| 55 | - type = command.type, | |
| 56 | - active = command.active, | |
| 57 | - ).also { | |
| 58 | - it.ext = jsonMapper.writeValueAsString(canonicalExt) | |
| 59 | - }, | |
| 46 | + val location = Location( | |
| 47 | + code = command.code, | |
| 48 | + name = command.name, | |
| 49 | + type = command.type, | |
| 50 | + active = command.active, | |
| 60 | 51 | ) |
| 52 | + extValidator.applyTo(location, command.ext) | |
| 53 | + return locations.save(location) | |
| 61 | 54 | } |
| 62 | 55 | |
| 63 | 56 | fun update(id: UUID, command: UpdateLocationCommand): Location { |
| ... | ... | @@ -67,10 +60,7 @@ class LocationService( |
| 67 | 60 | command.name?.let { location.name = it } |
| 68 | 61 | command.type?.let { location.type = it } |
| 69 | 62 | command.active?.let { location.active = it } |
| 70 | - if (command.ext != null) { | |
| 71 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | |
| 72 | - location.ext = jsonMapper.writeValueAsString(canonicalExt) | |
| 73 | - } | |
| 63 | + extValidator.applyTo(location, command.ext) | |
| 74 | 64 | return location |
| 75 | 65 | } |
| 76 | 66 | |
| ... | ... | @@ -81,17 +71,14 @@ class LocationService( |
| 81 | 71 | location.active = false |
| 82 | 72 | } |
| 83 | 73 | |
| 84 | - @Suppress("UNCHECKED_CAST") | |
| 85 | - fun parseExt(location: Location): Map<String, Any?> = try { | |
| 86 | - if (location.ext.isBlank()) emptyMap() | |
| 87 | - else jsonMapper.readValue(location.ext, Map::class.java) as Map<String, Any?> | |
| 88 | - } catch (ex: Throwable) { | |
| 89 | - emptyMap() | |
| 90 | - } | |
| 91 | - | |
| 92 | - companion object { | |
| 93 | - const val ENTITY_NAME: String = "Location" | |
| 94 | - } | |
| 74 | + /** | |
| 75 | + * Convenience passthrough for response mappers — delegates to | |
| 76 | + * [ExtJsonValidator.parseExt]. Kept here as a thin wrapper so | |
| 77 | + * the controller's mapper doesn't have to inject the validator | |
| 78 | + * directly. | |
| 79 | + */ | |
| 80 | + fun parseExt(location: Location): Map<String, Any?> = | |
| 81 | + extValidator.parseExt(location) | |
| 95 | 82 | } |
| 96 | 83 | |
| 97 | 84 | data class CreateLocationCommand( | ... | ... |
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt
| ... | ... | @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated |
| 7 | 7 | import jakarta.persistence.Table |
| 8 | 8 | import org.hibernate.annotations.JdbcTypeCode |
| 9 | 9 | import org.hibernate.type.SqlTypes |
| 10 | +import org.vibeerp.api.v1.entity.HasExt | |
| 10 | 11 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 11 | 12 | |
| 12 | 13 | /** |
| ... | ... | @@ -50,7 +51,7 @@ class Location( |
| 50 | 51 | name: String, |
| 51 | 52 | type: LocationType, |
| 52 | 53 | active: Boolean = true, |
| 53 | -) : AuditedJpaEntity() { | |
| 54 | +) : AuditedJpaEntity(), HasExt { | |
| 54 | 55 | |
| 55 | 56 | @Column(name = "code", nullable = false, length = 64) |
| 56 | 57 | var code: String = code |
| ... | ... | @@ -67,10 +68,17 @@ class Location( |
| 67 | 68 | |
| 68 | 69 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 69 | 70 | @JdbcTypeCode(SqlTypes.JSON) |
| 70 | - var ext: String = "{}" | |
| 71 | + override var ext: String = "{}" | |
| 72 | + | |
| 73 | + override val extEntityName: String get() = ENTITY_NAME | |
| 71 | 74 | |
| 72 | 75 | override fun toString(): String = |
| 73 | 76 | "Location(id=$id, code='$code', type=$type, active=$active)" |
| 77 | + | |
| 78 | + companion object { | |
| 79 | + /** Key under which Location's custom fields are declared in `metadata__custom_field`. */ | |
| 80 | + const val ENTITY_NAME: String = "Location" | |
| 81 | + } | |
| 74 | 82 | } |
| 75 | 83 | |
| 76 | 84 | /** | ... | ... |
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt
| ... | ... | @@ -6,12 +6,15 @@ import assertk.assertions.hasMessage |
| 6 | 6 | import assertk.assertions.isEqualTo |
| 7 | 7 | import assertk.assertions.isFalse |
| 8 | 8 | import assertk.assertions.isInstanceOf |
| 9 | +import io.mockk.Runs | |
| 9 | 10 | import io.mockk.every |
| 11 | +import io.mockk.just | |
| 10 | 12 | import io.mockk.mockk |
| 11 | 13 | import io.mockk.slot |
| 12 | 14 | import io.mockk.verify |
| 13 | 15 | import org.junit.jupiter.api.BeforeEach |
| 14 | 16 | import org.junit.jupiter.api.Test |
| 17 | +import org.vibeerp.api.v1.entity.HasExt | |
| 15 | 18 | import org.vibeerp.pbc.inventory.domain.Location |
| 16 | 19 | import org.vibeerp.pbc.inventory.domain.LocationType |
| 17 | 20 | import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository |
| ... | ... | @@ -29,7 +32,11 @@ class LocationServiceTest { |
| 29 | 32 | fun setUp() { |
| 30 | 33 | locations = mockk() |
| 31 | 34 | extValidator = mockk() |
| 32 | - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } | |
| 35 | + // applyTo is a void method with side effects on the entity; | |
| 36 | + // for unit tests we treat it as a no-op. Tests that exercise | |
| 37 | + // the ext path directly can stub concrete behavior. | |
| 38 | + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs | |
| 39 | + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap() | |
| 33 | 40 | service = LocationService(locations, extValidator) |
| 34 | 41 | } |
| 35 | 42 | ... | ... |
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt
| 1 | 1 | package org.vibeerp.pbc.orders.purchase.application |
| 2 | 2 | |
| 3 | -import com.fasterxml.jackson.databind.ObjectMapper | |
| 4 | -import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 5 | 3 | import org.springframework.stereotype.Service |
| 6 | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | 5 | import org.vibeerp.api.v1.event.EventBus |
| ... | ... | @@ -55,8 +53,6 @@ class PurchaseOrderService( |
| 55 | 53 | private val eventBus: EventBus, |
| 56 | 54 | ) { |
| 57 | 55 | |
| 58 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | |
| 59 | - | |
| 60 | 56 | @Transactional(readOnly = true) |
| 61 | 57 | fun list(): List<PurchaseOrder> = orders.findAll() |
| 62 | 58 | |
| ... | ... | @@ -112,8 +108,6 @@ class PurchaseOrderService( |
| 112 | 108 | acc + (line.quantity * line.unitPrice) |
| 113 | 109 | } |
| 114 | 110 | |
| 115 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | |
| 116 | - | |
| 117 | 111 | val order = PurchaseOrder( |
| 118 | 112 | code = command.code, |
| 119 | 113 | partnerCode = command.partnerCode, |
| ... | ... | @@ -122,9 +116,8 @@ class PurchaseOrderService( |
| 122 | 116 | expectedDate = command.expectedDate, |
| 123 | 117 | currencyCode = command.currencyCode, |
| 124 | 118 | totalAmount = total, |
| 125 | - ).also { | |
| 126 | - it.ext = jsonMapper.writeValueAsString(canonicalExt) | |
| 127 | - } | |
| 119 | + ) | |
| 120 | + extValidator.applyTo(order, command.ext) | |
| 128 | 121 | for (line in command.lines) { |
| 129 | 122 | order.lines += PurchaseOrderLine( |
| 130 | 123 | purchaseOrder = order, |
| ... | ... | @@ -160,9 +153,8 @@ class PurchaseOrderService( |
| 160 | 153 | command.expectedDate?.let { order.expectedDate = it } |
| 161 | 154 | command.currencyCode?.let { order.currencyCode = it } |
| 162 | 155 | |
| 163 | - if (command.ext != null) { | |
| 164 | - order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext)) | |
| 165 | - } | |
| 156 | + // applyTo() is null-safe — a null command.ext is a no-op. | |
| 157 | + extValidator.applyTo(order, command.ext) | |
| 166 | 158 | |
| 167 | 159 | if (command.lines != null) { |
| 168 | 160 | require(command.lines.isNotEmpty()) { |
| ... | ... | @@ -309,17 +301,13 @@ class PurchaseOrderService( |
| 309 | 301 | return order |
| 310 | 302 | } |
| 311 | 303 | |
| 312 | - @Suppress("UNCHECKED_CAST") | |
| 313 | - fun parseExt(order: PurchaseOrder): Map<String, Any?> = try { | |
| 314 | - if (order.ext.isBlank()) emptyMap() | |
| 315 | - else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?> | |
| 316 | - } catch (ex: Throwable) { | |
| 317 | - emptyMap() | |
| 318 | - } | |
| 319 | - | |
| 320 | - companion object { | |
| 321 | - const val ENTITY_NAME: String = "PurchaseOrder" | |
| 322 | - } | |
| 304 | + /** | |
| 305 | + * Convenience passthrough for response mappers — delegates to | |
| 306 | + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty | |
| 307 | + * or unparseable column so response rendering never 500s. | |
| 308 | + */ | |
| 309 | + fun parseExt(order: PurchaseOrder): Map<String, Any?> = | |
| 310 | + extValidator.parseExt(order) | |
| 323 | 311 | } |
| 324 | 312 | |
| 325 | 313 | data class CreatePurchaseOrderCommand( | ... | ... |
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt
| ... | ... | @@ -11,6 +11,7 @@ import jakarta.persistence.OrderBy |
| 11 | 11 | import jakarta.persistence.Table |
| 12 | 12 | import org.hibernate.annotations.JdbcTypeCode |
| 13 | 13 | import org.hibernate.type.SqlTypes |
| 14 | +import org.vibeerp.api.v1.entity.HasExt | |
| 14 | 15 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 15 | 16 | import java.math.BigDecimal |
| 16 | 17 | import java.time.LocalDate |
| ... | ... | @@ -70,7 +71,7 @@ class PurchaseOrder( |
| 70 | 71 | expectedDate: LocalDate? = null, |
| 71 | 72 | currencyCode: String, |
| 72 | 73 | totalAmount: BigDecimal = BigDecimal.ZERO, |
| 73 | -) : AuditedJpaEntity() { | |
| 74 | +) : AuditedJpaEntity(), HasExt { | |
| 74 | 75 | |
| 75 | 76 | @Column(name = "code", nullable = false, length = 64) |
| 76 | 77 | var code: String = code |
| ... | ... | @@ -96,7 +97,9 @@ class PurchaseOrder( |
| 96 | 97 | |
| 97 | 98 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 98 | 99 | @JdbcTypeCode(SqlTypes.JSON) |
| 99 | - var ext: String = "{}" | |
| 100 | + override var ext: String = "{}" | |
| 101 | + | |
| 102 | + override val extEntityName: String get() = ENTITY_NAME | |
| 100 | 103 | |
| 101 | 104 | @OneToMany( |
| 102 | 105 | mappedBy = "purchaseOrder", |
| ... | ... | @@ -109,6 +112,11 @@ class PurchaseOrder( |
| 109 | 112 | |
| 110 | 113 | override fun toString(): String = |
| 111 | 114 | "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)" |
| 115 | + | |
| 116 | + companion object { | |
| 117 | + /** Key under which PurchaseOrder's custom fields are declared in `metadata__custom_field`. */ | |
| 118 | + const val ENTITY_NAME: String = "PurchaseOrder" | |
| 119 | + } | |
| 112 | 120 | } |
| 113 | 121 | |
| 114 | 122 | /** | ... | ... |
pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt
| ... | ... | @@ -16,6 +16,7 @@ import io.mockk.verify |
| 16 | 16 | import org.junit.jupiter.api.BeforeEach |
| 17 | 17 | import org.junit.jupiter.api.Test |
| 18 | 18 | import org.vibeerp.api.v1.core.Id |
| 19 | +import org.vibeerp.api.v1.entity.HasExt | |
| 19 | 20 | import org.vibeerp.api.v1.event.EventBus |
| 20 | 21 | import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent |
| 21 | 22 | import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent |
| ... | ... | @@ -54,7 +55,8 @@ class PurchaseOrderServiceTest { |
| 54 | 55 | inventoryApi = mockk() |
| 55 | 56 | extValidator = mockk() |
| 56 | 57 | eventBus = mockk() |
| 57 | - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } | |
| 58 | + every { extValidator.applyTo(any<HasExt>(), any()) } just runs | |
| 59 | + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap() | |
| 58 | 60 | every { orders.existsByCode(any()) } returns false |
| 59 | 61 | every { orders.save(any<PurchaseOrder>()) } answers { firstArg() } |
| 60 | 62 | every { eventBus.publish(any()) } just runs | ... | ... |
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt
| 1 | 1 | package org.vibeerp.pbc.orders.sales.application |
| 2 | 2 | |
| 3 | -import com.fasterxml.jackson.databind.ObjectMapper | |
| 4 | -import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 5 | 3 | import org.springframework.stereotype.Service |
| 6 | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | 5 | import org.vibeerp.api.v1.event.EventBus |
| ... | ... | @@ -78,8 +76,6 @@ class SalesOrderService( |
| 78 | 76 | private val eventBus: EventBus, |
| 79 | 77 | ) { |
| 80 | 78 | |
| 81 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | |
| 82 | - | |
| 83 | 79 | @Transactional(readOnly = true) |
| 84 | 80 | fun list(): List<SalesOrder> = orders.findAll() |
| 85 | 81 | |
| ... | ... | @@ -138,8 +134,6 @@ class SalesOrderService( |
| 138 | 134 | acc + (line.quantity * line.unitPrice) |
| 139 | 135 | } |
| 140 | 136 | |
| 141 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | |
| 142 | - | |
| 143 | 137 | val order = SalesOrder( |
| 144 | 138 | code = command.code, |
| 145 | 139 | partnerCode = command.partnerCode, |
| ... | ... | @@ -147,9 +141,8 @@ class SalesOrderService( |
| 147 | 141 | orderDate = command.orderDate, |
| 148 | 142 | currencyCode = command.currencyCode, |
| 149 | 143 | totalAmount = total, |
| 150 | - ).also { | |
| 151 | - it.ext = jsonMapper.writeValueAsString(canonicalExt) | |
| 152 | - } | |
| 144 | + ) | |
| 145 | + extValidator.applyTo(order, command.ext) | |
| 153 | 146 | for (line in command.lines) { |
| 154 | 147 | order.lines += SalesOrderLine( |
| 155 | 148 | salesOrder = order, |
| ... | ... | @@ -184,9 +177,8 @@ class SalesOrderService( |
| 184 | 177 | command.orderDate?.let { order.orderDate = it } |
| 185 | 178 | command.currencyCode?.let { order.currencyCode = it } |
| 186 | 179 | |
| 187 | - if (command.ext != null) { | |
| 188 | - order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext)) | |
| 189 | - } | |
| 180 | + // applyTo() is null-safe — a null command.ext is a no-op. | |
| 181 | + extValidator.applyTo(order, command.ext) | |
| 190 | 182 | |
| 191 | 183 | if (command.lines != null) { |
| 192 | 184 | require(command.lines.isNotEmpty()) { |
| ... | ... | @@ -347,17 +339,13 @@ class SalesOrderService( |
| 347 | 339 | return order |
| 348 | 340 | } |
| 349 | 341 | |
| 350 | - @Suppress("UNCHECKED_CAST") | |
| 351 | - fun parseExt(order: SalesOrder): Map<String, Any?> = try { | |
| 352 | - if (order.ext.isBlank()) emptyMap() | |
| 353 | - else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?> | |
| 354 | - } catch (ex: Throwable) { | |
| 355 | - emptyMap() | |
| 356 | - } | |
| 357 | - | |
| 358 | - companion object { | |
| 359 | - const val ENTITY_NAME: String = "SalesOrder" | |
| 360 | - } | |
| 342 | + /** | |
| 343 | + * Convenience passthrough for response mappers — delegates to | |
| 344 | + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty | |
| 345 | + * or unparseable column so response rendering never 500s. | |
| 346 | + */ | |
| 347 | + fun parseExt(order: SalesOrder): Map<String, Any?> = | |
| 348 | + extValidator.parseExt(order) | |
| 361 | 349 | } |
| 362 | 350 | |
| 363 | 351 | data class CreateSalesOrderCommand( | ... | ... |
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt
| ... | ... | @@ -12,6 +12,7 @@ import jakarta.persistence.OrderBy |
| 12 | 12 | import jakarta.persistence.Table |
| 13 | 13 | import org.hibernate.annotations.JdbcTypeCode |
| 14 | 14 | import org.hibernate.type.SqlTypes |
| 15 | +import org.vibeerp.api.v1.entity.HasExt | |
| 15 | 16 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 16 | 17 | import java.math.BigDecimal |
| 17 | 18 | import java.time.LocalDate |
| ... | ... | @@ -76,7 +77,7 @@ class SalesOrder( |
| 76 | 77 | orderDate: LocalDate, |
| 77 | 78 | currencyCode: String, |
| 78 | 79 | totalAmount: BigDecimal = BigDecimal.ZERO, |
| 79 | -) : AuditedJpaEntity() { | |
| 80 | +) : AuditedJpaEntity(), HasExt { | |
| 80 | 81 | |
| 81 | 82 | @Column(name = "code", nullable = false, length = 64) |
| 82 | 83 | var code: String = code |
| ... | ... | @@ -99,7 +100,9 @@ class SalesOrder( |
| 99 | 100 | |
| 100 | 101 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 101 | 102 | @JdbcTypeCode(SqlTypes.JSON) |
| 102 | - var ext: String = "{}" | |
| 103 | + override var ext: String = "{}" | |
| 104 | + | |
| 105 | + override val extEntityName: String get() = ENTITY_NAME | |
| 103 | 106 | |
| 104 | 107 | /** |
| 105 | 108 | * The order's line items, eagerly loaded. Cascade ALL because |
| ... | ... | @@ -119,6 +122,11 @@ class SalesOrder( |
| 119 | 122 | |
| 120 | 123 | override fun toString(): String = |
| 121 | 124 | "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)" |
| 125 | + | |
| 126 | + companion object { | |
| 127 | + /** Key under which SalesOrder's custom fields are declared in `metadata__custom_field`. */ | |
| 128 | + const val ENTITY_NAME: String = "SalesOrder" | |
| 129 | + } | |
| 122 | 130 | } |
| 123 | 131 | |
| 124 | 132 | /** | ... | ... |
pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt
| ... | ... | @@ -17,6 +17,7 @@ import io.mockk.verify |
| 17 | 17 | import org.junit.jupiter.api.BeforeEach |
| 18 | 18 | import org.junit.jupiter.api.Test |
| 19 | 19 | import org.vibeerp.api.v1.core.Id |
| 20 | +import org.vibeerp.api.v1.entity.HasExt | |
| 20 | 21 | import org.vibeerp.api.v1.event.EventBus |
| 21 | 22 | import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent |
| 22 | 23 | import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent |
| ... | ... | @@ -54,7 +55,10 @@ class SalesOrderServiceTest { |
| 54 | 55 | inventoryApi = mockk() |
| 55 | 56 | extValidator = mockk() |
| 56 | 57 | eventBus = mockk() |
| 57 | - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } | |
| 58 | + // applyTo is a void with side effects; default to a no-op so | |
| 59 | + // tests that don't touch ext don't need to set expectations. | |
| 60 | + every { extValidator.applyTo(any<HasExt>(), any()) } just runs | |
| 61 | + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap() | |
| 58 | 62 | every { orders.existsByCode(any()) } returns false |
| 59 | 63 | every { orders.save(any<SalesOrder>()) } answers { firstArg() } |
| 60 | 64 | // The bus is fire-and-forget from the service's perspective. | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt
| 1 | 1 | package org.vibeerp.pbc.partners.application |
| 2 | 2 | |
| 3 | -import com.fasterxml.jackson.databind.ObjectMapper | |
| 4 | -import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 5 | 3 | import org.springframework.stereotype.Service |
| 6 | 4 | import org.springframework.transaction.annotation.Transactional |
| 7 | 5 | import org.vibeerp.pbc.partners.domain.Partner |
| ... | ... | @@ -50,8 +48,6 @@ class PartnerService( |
| 50 | 48 | private val extValidator: ExtJsonValidator, |
| 51 | 49 | ) { |
| 52 | 50 | |
| 53 | - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule() | |
| 54 | - | |
| 55 | 51 | @Transactional(readOnly = true) |
| 56 | 52 | fun list(): List<Partner> = partners.findAll() |
| 57 | 53 | |
| ... | ... | @@ -65,24 +61,22 @@ class PartnerService( |
| 65 | 61 | require(!partners.existsByCode(command.code)) { |
| 66 | 62 | "partner code '${command.code}' is already taken" |
| 67 | 63 | } |
| 68 | - // Validate the ext JSONB before save (P3.4 — Tier 1 customization). | |
| 69 | - // Throws IllegalArgumentException with all violations on failure; | |
| 70 | - // GlobalExceptionHandler maps it to 400 Bad Request. | |
| 71 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | |
| 72 | - return partners.save( | |
| 73 | - Partner( | |
| 74 | - code = command.code, | |
| 75 | - name = command.name, | |
| 76 | - type = command.type, | |
| 77 | - taxId = command.taxId, | |
| 78 | - website = command.website, | |
| 79 | - email = command.email, | |
| 80 | - phone = command.phone, | |
| 81 | - active = command.active, | |
| 82 | - ).also { | |
| 83 | - it.ext = jsonMapper.writeValueAsString(canonicalExt) | |
| 84 | - }, | |
| 64 | + // P3.4 — Tier 1 customization. applyTo() validates, canonicalises, | |
| 65 | + // and writes to partner.ext. Throws IllegalArgumentException with | |
| 66 | + // all violations joined if validation fails; GlobalExceptionHandler | |
| 67 | + // maps that to 400 Bad Request. | |
| 68 | + val partner = Partner( | |
| 69 | + code = command.code, | |
| 70 | + name = command.name, | |
| 71 | + type = command.type, | |
| 72 | + taxId = command.taxId, | |
| 73 | + website = command.website, | |
| 74 | + email = command.email, | |
| 75 | + phone = command.phone, | |
| 76 | + active = command.active, | |
| 85 | 77 | ) |
| 78 | + extValidator.applyTo(partner, command.ext) | |
| 79 | + return partners.save(partner) | |
| 86 | 80 | } |
| 87 | 81 | |
| 88 | 82 | fun update(id: UUID, command: UpdatePartnerCommand): Partner { |
| ... | ... | @@ -102,37 +96,18 @@ class PartnerService( |
| 102 | 96 | // updates would force callers to merge with the existing JSON |
| 103 | 97 | // before sending, which is the kind of foot-gun the framework |
| 104 | 98 | // should not normalise. PATCH is for top-level columns only. |
| 105 | - if (command.ext != null) { | |
| 106 | - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext) | |
| 107 | - partner.ext = jsonMapper.writeValueAsString(canonicalExt) | |
| 108 | - } | |
| 99 | + // applyTo() is null-safe — a null command.ext is a no-op. | |
| 100 | + extValidator.applyTo(partner, command.ext) | |
| 109 | 101 | return partner |
| 110 | 102 | } |
| 111 | 103 | |
| 112 | 104 | /** |
| 113 | - * Parse the entity's `ext` JSON string back into a Map for the | |
| 114 | - * REST response. Returns an empty map when the column is empty | |
| 115 | - * or unparseable — the latter shouldn't happen because every | |
| 116 | - * write goes through [extValidator], but cleaning up bad rows | |
| 117 | - * from old data isn't this method's responsibility. | |
| 105 | + * Convenience passthrough for response mappers — delegates to | |
| 106 | + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty | |
| 107 | + * or unparseable column so response rendering never 500s. | |
| 118 | 108 | */ |
| 119 | - @Suppress("UNCHECKED_CAST") | |
| 120 | - fun parseExt(partner: Partner): Map<String, Any?> = try { | |
| 121 | - if (partner.ext.isBlank()) emptyMap() | |
| 122 | - else jsonMapper.readValue(partner.ext, Map::class.java) as Map<String, Any?> | |
| 123 | - } catch (ex: Throwable) { | |
| 124 | - emptyMap() | |
| 125 | - } | |
| 126 | - | |
| 127 | - companion object { | |
| 128 | - /** | |
| 129 | - * The entity name the partner aggregate is registered under in | |
| 130 | - * `metadata__entity` (and therefore the key the [ExtJsonValidator] | |
| 131 | - * looks up). Kept as a constant so the controller and tests | |
| 132 | - * can reference the same string without hard-coding it twice. | |
| 133 | - */ | |
| 134 | - const val ENTITY_NAME: String = "Partner" | |
| 135 | - } | |
| 109 | + fun parseExt(partner: Partner): Map<String, Any?> = | |
| 110 | + extValidator.parseExt(partner) | |
| 136 | 111 | |
| 137 | 112 | /** |
| 138 | 113 | * Deactivate the partner and all its contacts. Addresses are left | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Partner.kt
| ... | ... | @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated |
| 7 | 7 | import jakarta.persistence.Table |
| 8 | 8 | import org.hibernate.annotations.JdbcTypeCode |
| 9 | 9 | import org.hibernate.type.SqlTypes |
| 10 | +import org.vibeerp.api.v1.entity.HasExt | |
| 10 | 11 | import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 11 | 12 | |
| 12 | 13 | /** |
| ... | ... | @@ -58,7 +59,7 @@ class Partner( |
| 58 | 59 | email: String? = null, |
| 59 | 60 | phone: String? = null, |
| 60 | 61 | active: Boolean = true, |
| 61 | -) : AuditedJpaEntity() { | |
| 62 | +) : AuditedJpaEntity(), HasExt { | |
| 62 | 63 | |
| 63 | 64 | @Column(name = "code", nullable = false, length = 64) |
| 64 | 65 | var code: String = code |
| ... | ... | @@ -87,10 +88,17 @@ class Partner( |
| 87 | 88 | |
| 88 | 89 | @Column(name = "ext", nullable = false, columnDefinition = "jsonb") |
| 89 | 90 | @JdbcTypeCode(SqlTypes.JSON) |
| 90 | - var ext: String = "{}" | |
| 91 | + override var ext: String = "{}" | |
| 92 | + | |
| 93 | + override val extEntityName: String get() = ENTITY_NAME | |
| 91 | 94 | |
| 92 | 95 | override fun toString(): String = |
| 93 | 96 | "Partner(id=$id, code='$code', type=$type, active=$active)" |
| 97 | + | |
| 98 | + companion object { | |
| 99 | + /** Key under which Partner's custom fields are declared in `metadata__custom_field`. */ | |
| 100 | + const val ENTITY_NAME: String = "Partner" | |
| 101 | + } | |
| 94 | 102 | } |
| 95 | 103 | |
| 96 | 104 | /** | ... | ... |
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt
| ... | ... | @@ -6,12 +6,15 @@ import assertk.assertions.hasMessage |
| 6 | 6 | import assertk.assertions.isEqualTo |
| 7 | 7 | import assertk.assertions.isFalse |
| 8 | 8 | import assertk.assertions.isInstanceOf |
| 9 | +import io.mockk.Runs | |
| 9 | 10 | import io.mockk.every |
| 11 | +import io.mockk.just | |
| 10 | 12 | import io.mockk.mockk |
| 11 | 13 | import io.mockk.slot |
| 12 | 14 | import io.mockk.verify |
| 13 | 15 | import org.junit.jupiter.api.BeforeEach |
| 14 | 16 | import org.junit.jupiter.api.Test |
| 17 | +import org.vibeerp.api.v1.entity.HasExt | |
| 15 | 18 | import org.vibeerp.pbc.partners.domain.Contact |
| 16 | 19 | import org.vibeerp.pbc.partners.domain.Partner |
| 17 | 20 | import org.vibeerp.pbc.partners.domain.PartnerType |
| ... | ... | @@ -36,9 +39,10 @@ class PartnerServiceTest { |
| 36 | 39 | addresses = mockk() |
| 37 | 40 | contacts = mockk() |
| 38 | 41 | extValidator = mockk() |
| 39 | - // Default: validator returns the input map unchanged. Tests | |
| 40 | - // that exercise ext directly override this expectation. | |
| 41 | - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } | |
| 42 | + // Default: validator treats applyTo as a no-op. Tests that | |
| 43 | + // exercise ext directly override this expectation. | |
| 44 | + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs | |
| 45 | + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap() | |
| 42 | 46 | service = PartnerService(partners, addresses, contacts, extValidator) |
| 43 | 47 | } |
| 44 | 48 | ... | ... |
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt
| 1 | 1 | package org.vibeerp.platform.metadata.customfield |
| 2 | 2 | |
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper | |
| 3 | 4 | import org.springframework.stereotype.Component |
| 4 | 5 | import org.vibeerp.api.v1.entity.CustomField |
| 5 | 6 | import org.vibeerp.api.v1.entity.FieldType |
| 7 | +import org.vibeerp.api.v1.entity.HasExt | |
| 6 | 8 | import java.math.BigDecimal |
| 7 | 9 | import java.time.LocalDate |
| 8 | 10 | import java.time.OffsetDateTime |
| ... | ... | @@ -53,9 +55,53 @@ import java.util.UUID |
| 53 | 55 | @Component |
| 54 | 56 | class ExtJsonValidator( |
| 55 | 57 | private val registry: CustomFieldRegistry, |
| 58 | + private val jsonMapper: ObjectMapper, | |
| 56 | 59 | ) { |
| 57 | 60 | |
| 58 | 61 | /** |
| 62 | + * Validate [ext], serialize the canonical form, and write it to | |
| 63 | + * [entity]'s ext column. Null [ext] is a no-op (the column keeps | |
| 64 | + * its prior value, which means PATCH-style "don't touch ext" | |
| 65 | + * works out of the box). | |
| 66 | + * | |
| 67 | + * This is the single entry point every core PBC service and | |
| 68 | + * every plug-in uses to stage an ext update. It replaces the | |
| 69 | + * four-line sequence (validate / writeValueAsString / assign to | |
| 70 | + * column) that used to live in every service — removing a lot | |
| 71 | + * of boilerplate and giving the framework one place to change | |
| 72 | + * the ext-save contract later (e.g. PII redaction, audit | |
| 73 | + * tagging, eventing on field-level changes). | |
| 74 | + * | |
| 75 | + * Throws [IllegalArgumentException] with all violations joined | |
| 76 | + * by `; ` if validation fails. The framework's | |
| 77 | + * `GlobalExceptionHandler` maps that to 400 Bad Request. | |
| 78 | + */ | |
| 79 | + fun applyTo(entity: HasExt, ext: Map<String, Any?>?) { | |
| 80 | + if (ext == null) return | |
| 81 | + val canonical = validate(entity.extEntityName, ext) | |
| 82 | + entity.ext = jsonMapper.writeValueAsString(canonical) | |
| 83 | + } | |
| 84 | + | |
| 85 | + /** | |
| 86 | + * Parse an entity's serialized `ext` column back into a | |
| 87 | + * `Map<String, Any?>` for response DTOs. Returns an empty map | |
| 88 | + * for any parse failure — response mappers should NOT fail a | |
| 89 | + * successful read because of a bad serialized ext value; if the | |
| 90 | + * column is corrupt, the fix is a save, not a 500 on read. | |
| 91 | + * | |
| 92 | + * This replaces the `parseExt(...)` helper that was duplicated | |
| 93 | + * verbatim in every core PBC service. | |
| 94 | + */ | |
| 95 | + @Suppress("UNCHECKED_CAST") | |
| 96 | + fun parseExt(entity: HasExt): Map<String, Any?> = | |
| 97 | + try { | |
| 98 | + if (entity.ext.isBlank()) emptyMap() | |
| 99 | + else jsonMapper.readValue(entity.ext, Map::class.java) as Map<String, Any?> | |
| 100 | + } catch (ex: Throwable) { | |
| 101 | + emptyMap() | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** | |
| 59 | 105 | * Validate (and canonicalise) the [ext] map for [entityName]. |
| 60 | 106 | * |
| 61 | 107 | * @return a fresh map containing only the declared fields, with | ... | ... |
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt
| ... | ... | @@ -9,12 +9,15 @@ import assertk.assertions.isEmpty |
| 9 | 9 | import assertk.assertions.isEqualTo |
| 10 | 10 | import assertk.assertions.isInstanceOf |
| 11 | 11 | import assertk.assertions.messageContains |
| 12 | +import com.fasterxml.jackson.databind.ObjectMapper | |
| 13 | +import com.fasterxml.jackson.module.kotlin.registerKotlinModule | |
| 12 | 14 | import io.mockk.every |
| 13 | 15 | import io.mockk.mockk |
| 14 | 16 | import org.junit.jupiter.api.BeforeEach |
| 15 | 17 | import org.junit.jupiter.api.Test |
| 16 | 18 | import org.vibeerp.api.v1.entity.CustomField |
| 17 | 19 | import org.vibeerp.api.v1.entity.FieldType |
| 20 | +import org.vibeerp.api.v1.entity.HasExt | |
| 18 | 21 | |
| 19 | 22 | class ExtJsonValidatorTest { |
| 20 | 23 | |
| ... | ... | @@ -24,7 +27,7 @@ class ExtJsonValidatorTest { |
| 24 | 27 | @BeforeEach |
| 25 | 28 | fun setUp() { |
| 26 | 29 | registry = mockk() |
| 27 | - validator = ExtJsonValidator(registry) | |
| 30 | + validator = ExtJsonValidator(registry, ObjectMapper().registerKotlinModule()) | |
| 28 | 31 | } |
| 29 | 32 | |
| 30 | 33 | private fun stub(vararg fields: CustomField) { |
| ... | ... | @@ -170,6 +173,72 @@ class ExtJsonValidatorTest { |
| 170 | 173 | assertThat(canonical.keys).hasSize(0) |
| 171 | 174 | } |
| 172 | 175 | |
| 176 | + // ─── applyTo + parseExt (HasExt convenience helpers) ───────────── | |
| 177 | + | |
| 178 | + /** | |
| 179 | + * Minimal [HasExt] fixture. Services will mix this marker into | |
| 180 | + * their JPA entities; the test just needs something concrete. | |
| 181 | + */ | |
| 182 | + private class FakeEntity( | |
| 183 | + override val extEntityName: String = ENTITY, | |
| 184 | + override var ext: String = "{}", | |
| 185 | + ) : HasExt | |
| 186 | + | |
| 187 | + @Test | |
| 188 | + fun `applyTo with null ext is a no-op`() { | |
| 189 | + val fake = FakeEntity() | |
| 190 | + validator.applyTo(fake, null) | |
| 191 | + assertThat(fake.ext).isEqualTo("{}") | |
| 192 | + } | |
| 193 | + | |
| 194 | + @Test | |
| 195 | + fun `applyTo validates and writes canonical JSON to entity ext`() { | |
| 196 | + stub( | |
| 197 | + CustomField("label", ENTITY, FieldType.String(maxLength = 16)), | |
| 198 | + CustomField("count", ENTITY, FieldType.Integer), | |
| 199 | + ) | |
| 200 | + val fake = FakeEntity() | |
| 201 | + | |
| 202 | + validator.applyTo(fake, mapOf("label" to "ok", "count" to 3)) | |
| 203 | + | |
| 204 | + // Canonical form — label first (declared order), count as Long. | |
| 205 | + assertThat(fake.ext).isEqualTo("""{"label":"ok","count":3}""") | |
| 206 | + } | |
| 207 | + | |
| 208 | + @Test | |
| 209 | + fun `applyTo surfaces validation failures as IllegalArgumentException`() { | |
| 210 | + stub(CustomField("known", ENTITY, FieldType.Integer)) | |
| 211 | + val fake = FakeEntity() | |
| 212 | + | |
| 213 | + assertFailure { validator.applyTo(fake, mapOf("rogue" to "x")) } | |
| 214 | + .isInstanceOf(IllegalArgumentException::class) | |
| 215 | + .messageContains("undeclared") | |
| 216 | + // Ext was NOT touched on failure. | |
| 217 | + assertThat(fake.ext).isEqualTo("{}") | |
| 218 | + } | |
| 219 | + | |
| 220 | + @Test | |
| 221 | + fun `parseExt returns empty map for blank ext column`() { | |
| 222 | + val fake = FakeEntity(ext = "") | |
| 223 | + assertThat(validator.parseExt(fake)).isEmpty() | |
| 224 | + } | |
| 225 | + | |
| 226 | + @Test | |
| 227 | + fun `parseExt round-trips a previously-canonicalised ext value`() { | |
| 228 | + val fake = FakeEntity(ext = """{"label":"round-trip","count":42}""") | |
| 229 | + val parsed = validator.parseExt(fake) | |
| 230 | + assertThat(parsed.keys).contains("label") | |
| 231 | + assertThat(parsed.keys).contains("count") | |
| 232 | + assertThat(parsed["label"]).isEqualTo("round-trip") | |
| 233 | + } | |
| 234 | + | |
| 235 | + @Test | |
| 236 | + fun `parseExt swallows malformed JSON and returns empty map`() { | |
| 237 | + val fake = FakeEntity(ext = "this is not json") | |
| 238 | + // Response mappers should NOT 500 on a corrupt row. | |
| 239 | + assertThat(validator.parseExt(fake)).isEmpty() | |
| 240 | + } | |
| 241 | + | |
| 173 | 242 | companion object { |
| 174 | 243 | const val ENTITY: String = "TestEntity" |
| 175 | 244 | } | ... | ... |
-
mentioned in commit 8b95af94