Skip to content

Errors/7 problem schemas rebased - #109

Closed
olavgg wants to merge 22 commits into
mainfrom
errors/7-problem-schemas-rebased
Closed

olavgg wants to merge 22 commits into
mainfrom
errors/7-problem-schemas-rebased

Conversation

@olavgg

@olavgg olavgg commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

What this changes

How it was verified

Checklist

  • Pull requests this one depends on are named above and merged first, or there are none
  • Commits are signed off (git commit -s), per CONTRIBUTING.md
  • ./gradlew build passes
  • Behaviour visible outside this repo is reflected in the documentation, or does not need to be
    (see AGENTS.md for which of the two documentation sites it belongs in)

JosteinGj and others added 22 commits September 15, 2026 15:26
Adds Problems, the conventions for RFC 9457 problem responses. Nothing uses it
yet; the advices and controllers follow.

Errors were rendered four ways: ten advices returned ProblemDetail, two returned
a ResponseError<T> wrapper, controllers hand-rolled bare strings in 27 places,
and BuildErrorResponse returned a DataWrapper — a success-shaped envelope, so a
validation failure came back as {"items":[{"externalId":"must not be blank"}]}
and a client could not tell it from a successful listing by shape.

Three things the class settles:

type is the contract, and there is one host. Prose changes, a URI does not.
UserInfoRejectedExceptionHandler currently mints datahub.intellistream.ai while
everything else uses intellistream.ai — a second host for one scheme, which is
what declaring the types as constants is meant to stop.

fields keeps what the old shape threw away. FieldValidationError carries an
i18n key and its arguments — resource.source.max.length.error with the offending
length — and BuildErrorResponse collapsed each into Map.of(path, message),
dropping both. Callers got English prose they could neither localise nor read
the limit from. The extension carries all four parts, so the response is more
useful than the one it replaces.

Extensions are RFC 9457's own mechanism (§3.2) and consumers must ignore
unrecognised ones, so adding a member later cannot break a conforming client —
unlike adding a field to a bespoke wrapper a strict deserializer may reject.

ProblemWireShapeTest renders one through MVC rather than a plain mapper: the
mixin that flattens extensions to the top level is applied by the message
converter, not the type, so a unit test alone would have suggested callers
should read $.properties.fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
Converts the two wrapper-shaped advices and adds the three that were missing.
Controllers still catch most of these locally, so their bodies are unchanged
until the next commit removes those catches.

BadRequestExceptionHandler and ConcurrencyExceptionHandler now return
ProblemDetail. The former's javadoc argued against exactly this, and the
argument was sound as stated: controllers catch BadRequestException locally and
return the wrapper, so converting the advice alone gives one exception two
shapes. That is an argument against changing it in isolation, not against the
shape — the local catches go in the same change.

ConflictError.cause = "concurrency" becomes the type .../errors/optimistic-lock.
It existed so clients could discriminate without string-matching the message,
which is what RFC 9457's type member is for.

Three new advices. ConstraintViolationException had none, so seventeen
controllers caught it themselves — twenty-six blocks calling BuildErrorResponse,
which returns a DataWrapper: a success-shaped envelope used as an error body.
DuplicateDataException had none, so fourteen controllers read the status out of
the payload with HttpStatusCode.valueOf(error.getCode()). MethodArgumentNotValid
had none, which is why adding @Valid to the thirty-six unvalidated bodies was
held: without a handler it would have introduced a fourth shape rather than
removing shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
BREAKING: a failure that was reported as 500 with a bare string now arrives as
the problem its type describes — 400, 403, 404, 409 or 503 — in RFC 9457 shape.

Removing the 66 redundant catches alone would have made things worse. Each sat
above a catch (PulsarClientException | RuntimeException) answering 500, and
ConstraintViolationException, BadRequestException and DuplicateDataException all
extend RuntimeException: delete their handlers and they land in the net below
and become 500s instead of reaching the new advices. The catch-alls had to go
first.

They were already being fought. Thirty-nine of them across twelve controllers,
and fifty-two explicit "throw e;" rethrows existing only to escape them — two
carrying comments saying exactly that, one about NamingPolicyViolationException
being flattened and one about a limit refusal becoming a 500. Removing the nets
makes the rethrows unnecessary, and the specific catches with them: 56
try-statements rewritten, and every "Internal programming error." gone.

Pulsar publish failures were caught by those nets. They are checked, so the
handlers declare them, and MessagingUnavailableExceptionHandler answers 503
rather than 500 — the request was well formed and retrying is right.

NodeFamilyParityTest F9 is inverted rather than deleted. It required a local
catch for DuplicateDataException because without one the catch-all produced a
bare 500; it now requires the absence of one, since a local catch re-implements
the advice and is free to drift from it. F9b pins the advice it now relies on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
BREAKING: bare-string error bodies are now problem documents. A 404 that said
"File or folder not found." now says it in `detail`, beside a type and a status.

Fourteen bare strings converted, the two inline builders in
GraphTransferController and the five in LabelController with them, and
DataIntegrityViolationExceptionHandler takes over the constraint-name mapping
that seven controllers each called BuildErrorResponse for. That class is gone:
it returned a DataWrapper, so every validation failure and every unique-index
collision came back in the same envelope as a successful listing, and no client
could tell them apart by shape.

An unrecognised constraint stays a 409 with no `fields` rather than guessing a
field name, and logs the constraint for whoever adds the mapping.

The console reads the new shape. It was already problem-aware in places —
problemMessage() and a `json.detail || json.title` path existed — but duplicate
feedback was keyed on json.error.duplicated, which a problem document does not
have, so a taken external id would have degraded from a field marker to a
generic flash. `fields` maps straight onto the {field, message} shape the forms
already mark up, so every rejected field is marked now, not just a duplicate.
Both shapes are read while the two coexist.

DuplicateDataException gains the constructor its throw sites actually wanted:
what collided and the identifiers, rather than five lines building a wrapper the
advice immediately unwraps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
…ailure

ResourceDeleteException was the last exception in the API still answering
in the ResponseError envelope. Seven controllers caught it and returned
the body it carried, so a client that had moved to problem+json for
everything else still had to special-case delete.

An eighth never caught it: POST /policies/delete reaches the same graph
guards through PolicyService.deletePolicies, so a refused policy delete
came back as a bare 500 naming nothing. That is what handling it in one
place fixes, rather than writing the missing catch.

The status moves 400 -> 409. Nothing is wrong with the request: it is
well-formed, the ids exist, and it will succeed verbatim once the
subscription is removed or the stranded nodes are included. That is a
conflict with the current state, and it puts a refused delete next to the
API's other 409s — a taken external id, a lost optimistic lock — which
are the same "retry once the world changes" answer. A 400 told clients to
fix a payload that was never the problem.

The exception now carries the facts instead of a rendered body: a type
URI (referenced / would-strand — the two guards want different things
from the caller), the detail, and the blockers. The blockers keep their
own `blockedBy` member rather than going through the field -> message
bridge, which would turn one subscription into four unrelated field
errors and lose the external id the caller needs to go and delete.

  {"error":{"code":400,"message":"...","fields":[{"type":"subscription",...}]}}

becomes

  {"type":"https://intellistream.ai/errors/referenced",
   "title":"Delete refused","status":409,"detail":"...",
   "blockedBy":[{"subscriptionId":"9","subscriptionExternalId":"sub_a",
                 "timeseriesId":"5"}]}

The OpenAPI moves with it. Seven delete endpoints now document a single
409 covering both the refusal and the optimistic-lock conflict, told
apart by `type`; /resources/delete and /timeseries/delete lose the 400
that described the refusal with a BadRequestError example.

EventController is the exception: EventService.delete is ClickHouse,
KVRocks and Pulsar only and never reaches the graph guards, so its catch
was dead code and its docs need no 409.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
The last step of the error consolidation. ResponseError<T> was a wrapper
whose only job was to be unwrapped: a throw site built a rendered
response body, and the advice immediately took it apart again to build
the problem it actually answers with. Gone, with BadRequestError,
DuplicateError, ConflictError and commons' FieldError.

The exceptions carry facts now, not bodies. BadRequestException holds a
detail and the rejected fields; DuplicateDataException holds a detail and
what collided; InvalidResourceException, which is internal and never
rendered, holds a field and a message instead of assembling an HTTP body
two modules away from anything that serves a request. FieldErrors
replaces the accumulate-into-a-nested-DTO idiom at the twelve throw sites
that report several bad fields at once, and produces the same `fields`
entries a bean-validation failure does.

The `code` fields went too. They were always the status they were sent
alongside, and reading one back out of a payload —
HttpStatusCode.valueOf(error.getError().getCode()) — was how two
endpoints decided what to answer.

Three defects fell out of the sweep:

- TimeseriesService.updateTimeseries NPE'd on an invalid update: the
  ResponseError was created without a BadRequestError inside it, so the
  first addFieldError dereferenced null. An invalid /timeseries/update
  came back as a 500, not the documented 400.
- POST /edges/types/create returned a bare BadRequestError outside any
  envelope — the one endpoint answering with a naked error object.
- POST /timeseries/data reported partial failure as a DataWrapper whose
  `items` were error objects: a success-shaped envelope a client could
  not tell from a listing. It is a 404 problem with a `missing` member
  now, and insertDatapoints returns the misses rather than a wrapper.

OpenAPI moves with the code. Every @Schema(implementation = *Error.class)
now points at ProblemDetail with mediaType application/problem+json, and
the four documented 422s are gone — validation failures are 400 and
always have been, so the page promised a status the API never returns.

2193 tests green across all modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
Three paths still produced a response no client could read as an error.

An exception no advice handles escaped the controller, and CachingBodyFilter
swallowed the ServletException, so the caller got 200 with an empty body.
POST /events/byids with more than 10 000 ids answered 200. The filter now
lets it propagate, and ProblemErrorController renders the container's error
dispatch as a problem: a fixed 500 detail with the cause in the log, or the
status-level problem for Spring MVC's own refusals (404, 405, 415, a missing
parameter), whose wording Spring writes for callers. It replaces Boot's
BasicErrorController and its {timestamp,status,error,path} body.

A catch-all @ExceptionHandler(Exception.class) advice was the other option.
Most advices here carry no @order, so it would tie with them on the default
order, and Spring breaks that tie by bean registration order: which advice
answered would depend on classpath scan order.

The security chain answered 401 and 403 with no body. The 401 now says which
token check failed when a validator described it (ours or Spring Security's,
e.g. "Jwt expired at ..."), and never forwards a decoder's exception text. The
403 names the missing DATAHUB_ACCESS role and keeps the insufficient_scope
header. A token that fails verification is refused by the resource server's
own entry point, not exceptionHandling's, so both now use the same one.

Both write their body directly rather than calling sendError. The error
dispatch runs bearer authentication again: a bad token is refused a second
time with no body, and a good one sets TenantContext again after
RequestStateCleanupFilter has cleared it. That filter now also runs on the
error dispatch. TenantProvisioningFilter writes its 403 and 503 the same way,
with the unknown-tenant type the advice already used and a new
tenant-provisioning type.

/error is permitted, so an anonymous request that fails on a public path gets
its real status instead of 401.

SecurityFilterChainTest's malformed-token case passed for the wrong reason:
the unstubbed mock returned null, the provider threw an NPE, and the denied
error dispatch reported that 500 as the expected 401. The stub now throws
BadJwtException, as a real decoder does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
BREAKING: the listing limit, the files feature gate and the remaining file,
timeseries and policy refusals answer with problem documents instead of plain
text sent as application/json, which no JSON parser could read.

The earlier sweep matched `new ResponseEntity<>("...", status)` on one line.
These were split across lines, or passed a variable, and slipped past it:

- ListingLimit.rejection returns a validation-failed problem whose fields entry
  (field limit, code Max) matches what a @max failure on POST /filter gives.
  SubscriptionController had its own copy of the rule; it uses this one now.
- The files feature gate, nine times over, is a 403 feature-disabled problem
  naming the feature.
- PUT /files forwarded IllegalArgumentException text, which can be the JDK's
  ("For input string", URLDecoder's escape errors). The detail now says which
  X-Datahub-* headers to check. Bean-validation failures on the upload go
  through Problems.constraintViolation instead of a joined string, and the
  two write-permission refusals are forbidden problems.
- POST /files/restore caught every IllegalStateException and sent its text as
  a 409. That included UnknownTenantException. FileSystemService throws
  RestoreRefusedException for its four refusals now, with a stable reason
  token and a message that names no file; anything else is a 500.
- POST /files/update no longer echoes the rejected name or path.
- GET /files/list, /files/download and the download and delete I/O failures
  sent "" or a bare string.
- GET /timeseries sent NumberFormatException text ("For input string: abc").
- POST /policies/update answered an empty batch with {"items":[]} and forwarded
  IllegalArgumentException text.
- POST /resources/import kept a catch (IOException | RuntimeException) that
  answered 500 with no body, swallowing tenant limits and anything else with an
  advice. It and the rethrow catches that only existed to escape it are gone.

UserInfoRejectedExceptionHandler minted its type under datahub.intellistream.ai;
it uses Problems.BASE like everything else. permissions-unavailable sends
Retry-After as a header, as the 429s do, and retryAfter as a number rather than
the string "10".

Error responses that OpenAPI still described as a string example now declare
application/problem+json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
Two members on every problem document, so a client (a script, an SDK, an AI
agent) can act on a failure without reading prose.

requestId: RequestIdFilter gives each request an id. It keeps the caller's
X-Request-Id when that is short and plain ([A-Za-z0-9._:-], at most 64), so it
cannot forge a log line or a header, and mints a UUIDv7 otherwise. The id goes
back in the X-Request-Id response header, into the log context (both log
patterns print it) and into the problem. That is what lets the detail stay
incurious: the stack trace, the SQL and the upstream host remain in the log,
and the caller has the key to find them.

retry: one of three verdicts.
  same-request    the request can succeed unchanged later; honour Retry-After
                  (429, 503, a lost optimistic lock, messaging, provisioning)
  change-request  only a different request can succeed (400, 401, 404, 409
                  duplicate or refused delete, 413, 415)
  needs-operator  nothing the caller sends will succeed until someone acts
                  (403, a disabled feature, a tenant ceiling, an unknown
                  organization, any 500)
The verdict comes from the type where the type says more than the status (a
409 lost race versus a 409 duplicate), from the status otherwise, and a
problem that sets retry itself keeps it.

ProblemResponseAdvice adds both to every ProblemDetail leaving through MVC and
labels it application/problem+json. A handler declaring
produces = application/json used to send its problem as plain JSON, so
Content-Type could not tell a client it had an error. ProblemResponses does
the same for the filters. RateLimitFilter and RequestBodySizeLimitFilter now use
it instead of hand-formatted JSON strings, which would have broken on a quote
in the detail.

Bean-validation failures no longer echo the submitted value in
fields[].rejected. The value can be a credential (PUT /tenant/settings/llm
takes an apiKey) or a whole nested object, it is copied into proxies, logs and
agent transcripts, and the caller already has it. The hand-written validators
keep rejected for their i18n arguments: lengths and counts, and for an
externalId outside the allowed characters, the externalId itself, which is an
identifier and not a secret.

A JSON pointer per field was considered and left out: the bean property path
is not always the JSON name (@JsonProperty renames), and a pointer to the
wrong place is worse than the field name that is already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
Every service that raises a duplicate puts field -> value in duplicated:
{"externalId": "pump_7"}, {"name": "FLOWS_TO"}. The database-constraint
handler put field -> message there instead, {"name": "Label with same name
already exists."}, because a constraint violation names the field and not the
value. A client reading duplicated could not tell which of the two it had, and
the console rendered the message as if it were an external id.

The constraint handler now answers with fields, [{field, message}], the member
every other "this field is the problem" answer uses, and leaves duplicated to
the answers that know the value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The api now answers every failure with RFC 9457, and several pages still read
the {"error":{message,fields,duplicated}} envelope that is gone, or showed the
raw response body to the user.

DataHubProblem (static/js/datahub-problem.js, in the site-wide bundle) is an
ES6 class that reads a failed response once. DataHubProblem.read(response)
never rejects: an empty body, an HTML gateway page or the console's own
{errors:[...]} all become a DataHubProblem, so a caller does not have to guess
the shape first. An instance gives:

  message(fallbackKey)  one sentence in the user's language, by type (access
                        denials, limits, a lost optimistic lock, a duplicate,
                        a refused delete, provisioning, an internal error),
                        then by status, then the api's own detail
  fieldErrors()         fields and duplicated as {field, message}, the shape
                        the right-hand forms mark up
  details()             what the problem named (blockedBy, missing, violations,
                        unknown fields) and, when retry is needs-operator or the
                        status is 5xx, the requestId to quote to an operator
  flash(fallbackKey)    the message and details as a flash

The body stays on problem.body instead of being copied onto the instance, so a
member called message or details cannot shadow the method.

Moved onto it:
- the right-hand forms' handleResponse and delete, which also gain a sentence
  on every refusal (a form that redraws unchanged reads as a dead button); the
  label form's own copy of the envelope reader is gone
- label delete, the AI settings form (fields are [{field, message}] now), graph
  export and import, file update, delete, upload and restore (restore says why
  by reason), the timeseries chart, and the analysis panels, which showed
  "HTTP 400: {json...}"
- the timeseries chart put the api's message into innerHTML; it is text now

The dataset picker posted to /datasets/list, which the api does not have, and
now lists with GET /datasets?limit=100.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The relationship-type picker and create form called the console's
/api/relationship proxy, which relayed through the deprecated DatahubApi Feign
client. They call GET /edges/types and POST /edges/types/create from the
browser now, with the user's bearer token, and read the api's {items:[...]}
envelope, so a refusal arrives as the problem document the form already reads.

The edit form is gone rather than moved. Its update and delete posted to
/api/relationship/update and /delete, which never existed, and its load called
the label endpoint with a relationship-type id. The api has no update or delete
for relationship types, so there is nothing for such a form to call; the list
no longer offers the pencil.

RelationshipApiController and the two Feign methods behind it are deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The label picker, the create and edit forms, the label colours behind the
resource lists and the tutorial's label lookup all called the console's
/api/label proxy. They call GET /labels, GET /labels/{id}, POST /labels/create
and POST /labels/update from the browser now, wrapping and unwrapping the
api's {items:[...]} envelope; label delete already went direct and now uses
the shared Api helper.

The proxy's save was also broken on this branch: it parsed a duplicate-name
refusal as {"error":{...}} and threw a NullPointerException on the problem
document that replaced it, so a taken name came back as a 500. Direct, the
form reads the problem's duplicated member and marks the name field.

LabelApiController, its four Feign methods and the message key only it used
are deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The unit picker, the timeseries form's unit lookup and the tutorial called the
console's /api/units proxy. They call GET /units and POST /units/byids from the
browser now.

The proxy's one piece of logic was sorting the list by name with the request
locale's collator. The picker sorts with localeCompare instead, which uses the
browser's locale; for a user whose browser and console language differ, units
with letters outside A-Z can order differently than before.

UnitApiController and its two Feign methods are deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The policy form loaded, created, updated and deleted through the console's
/api/policies proxy. It calls GET /policies/{id}, POST /policies/create,
POST /policies/update and DELETE /policies/delete from the browser now. The
policies page itself is still rendered from DatahubApi.getPolicies; the other
five Feign methods and PolicyApiController are deleted.

Moving it found a bug the proxy hid. The form sent and read isDeactivated, but
the api's property is deactivated. On create the proxy's lenient reader dropped
the unknown field, so it did no harm; on load the form always saw false, so
opening a deactivated policy offered "Deactivate", and saving any edit sent
deactivated: false and switched the policy back on. The form sends and reads
deactivated now, which the api's strict reader would otherwise have refused.

Calls that went through the Feign client were checked by the compiler against
api-model; calls from the browser are not. BrowserPayloadContractTest now pins
the bodies for the relationship-type, label, unit and policy calls moved so far,
which is how the isDeactivated mismatch surfaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
…tahub-api

The events page's dataset filter, the dataset search on the timeseries page and
the tutorial's dataset cleanup called the console's /api/datasets proxy. They
call GET /datasets?limit=100, POST /datasets/search and DELETE /datasets/delete
from the browser now.

The proxy's search caught every failure and answered 200 with no items; the
page keeps that, rendering an empty result on a refusal, since the search box
has nowhere to show an error.

DataSetApiController and two Feign methods are deleted. listDataSets stays: the
datasets, policies and findings pages are rendered from it on the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The timeseries form, the chart on the timeseries and insights pages, the chart's
zoom refetch, the timeseries search boxes and the tutorial called the console's
/api/timeseries proxy. They call POST /timeseries/create, /update, /byids,
/search, /data/list and DELETE /timeseries/delete from the browser now. The
timeseries page itself is still rendered from DatahubApi.getTimeseriesList.

The proxy's by-hours-ago endpoint turned {externalId, startTime, endTime,
granularity, raw} into a /timeseries/data/list filter. That now lives in
InsightsChart.requestDatapoints, used by all three chart callers: avg, min and
max per bucket ("1 min" unless given), or raw points, limit 100000. No caller
sent hoursAgo, so that branch is gone.

Sending the form's own fields to a strict reader found three bodies the proxy
had been quietly repairing or dropping:
- create posted every form input, including the metadata and relation row
  inputs and each relation's display name; it names its fields now
- update sent metadata as a list of {key, value}, which cannot bind to the
  api's map, so an edit with metadata rows never saved; it sends a map
- "is step" was never a timeseries property in the api, so the checkbox did
  nothing; it is removed with its message key

The edit form's related-resource lookup goes to POST /resources/fetch-related
directly as well. The topic-stats panel code posted to a console endpoint that
did not exist and was never called; it is removed with its message keys.

TimeseriesApiController, DatapointService, ExternalIdAndHours and six Feign
methods are deleted, and BrowserPayloadContractTest pins the new bodies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
The resource forms (create, edit, create-with-relation, clone), the edge forms,
the resource search pickers, the graph's loading and edge drawing, and the
tutorial's buildout and cleanup called the console's /api/resources and
/api/edges proxies. They call POST /resources/create, /update, /search,
/fetch-related, GET /resources/{id}, GET /edges/{id} and DELETE
/resources/delete and /edges/delete from the browser now. That was the last of
the JSON proxies: the DatahubApi Feign client is left with the six calls the
server-rendered pages make (resource roots, the dataset list, policies, the
timeseries list, tenant features and the file listing).

What ResourceApiService did moves into the forms:
- create sends {nodes:[node], relations:[...]} with one relation per picked type
  when opened from another node, naming each field, since the page's form also
  holds label, relation and metadata row inputs the api would refuse
- update sends {nodes:[{id, update}]}, setting every field the form shows and
  clearing the dataset when the picker is empty, as before
- a saved edge is posted as a relation to /resources/create, as before
- an edge update posts to /resources/update and hands the envelope to the graph;
  the proxy's follow-up /resources/byids refreshed nodes nobody read, so it is
  dropped
- the search pickers keep offering only assets, timeseries, functions, resources
  and datasets; that filter is ResourceList.searchBody, shared by all of them

The proxy used to merge policy warnings into the saved node so the form could
see them. The form reads the envelope now, where the warnings already are.

The tutorial's buildout passed the relationship type's id as a type name, which
the proxy forwarded as relationshipType; it sends relationshipTypeId now.

Refusals that were an empty 400 through the proxy now reach the form as
problems: a delete that would strand resources names them, and a drawn edge the
api refuses (a second BELONGS_TO into a data set) flashes why instead of
vanishing.

With every form direct, the form base loads from GET {path}/{id} and deletes
through DELETE {path}/delete itself, and its proxy submit is gone.
ResourceApiController, EdgeApiController, ResourceApiService, nine Feign
methods, ResourceWebForm and RelFormWithId (only the proxy read them) and
ResourceWebFormBindingTest are deleted; BrowserPayloadContractTest pins the
bodies that replace them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
… else

The create form posted every input it held. Once a metadata row was added that
included inputs named metadata[1].key and metadata[1].value, which the api
refuses as unknown fields, so creating a data set with metadata failed with a
400; and the metadata itself was never put in the body. The form names its
fields now and sends the rows as the metadata map, as the edit form already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
… call

The label delete and the resource picker search asked /token themselves and
opened the signed-out dialog on a 401. They now go through Api, whose token()
only rejected, so a signed-out user saw a console error and waited for the
login poll. Api.token() opens the dialog itself, which covers every call that
moved to Api on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
Pointing every error response at ProblemDetail.class documented the wrong
shape. Spring's ProblemDetail keeps extension members in a Map behind
getProperties(), so swagger-core generated:

  { type, title, status, detail, instance, properties: {…} }

The wire has no `properties` member. The Jackson mixin flattens
extensions to the top level and it is applied by the message converter,
not by the type, so schema generation never sees it. The published schema
advertised a map that never arrives and omitted `fields`, `duplicated`,
`blockedBy` and `missing`, which always do — a generated client would
read properties.fields and find nothing.

Five documentation-only classes state the emitted shape instead, in the
pattern the response envelopes already use: Problem, ValidationProblem
(+fields), DuplicateProblem (+duplicated), DeleteRefusedProblem
(+blockedBy), PartialWriteProblem (+missing), with FieldProblem for an
entry. 47 response declarations now name the one that fits, so an
endpoint's 409 says whether it can carry `blockedBy` or `duplicated`
rather than leaving both undocumented.

Those envelopes are also the repo's precedent for this drifting: they
each declared `items` long after the real wrapper had grown nextCursor.
So ProblemSchemaParityTest renders every problem Problems can build
through MVC, as a caller receives it, and fails if the emitted members
are not declared — or if a declared member is one nothing produces. It
also pins the absence of `properties`, which reappears the moment the
mixin stops being applied.

2202 tests green across all modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
On top of the change that gave every problem requestId and retry, the
published schemas understated the body again, and 27 error responses added
in the meantime no longer compiled.

- Those 27 still named ProblemDetail.class, in controllers whose import had
  gone. Each now names the schema that fits: 400s ValidationProblem, 403s
  and 404s ApiProblem, the file restore 409 RestoreRefusedProblem, and the
  other file 409s, which carry no extension, ApiProblem. The import is back
  only where code uses the type.
- ApiProblem declares retry and requestId, which every problem carries, and
  lists every problem type the API sends.
- DuplicateProblem declares fields: a database constraint names the field
  but not the value, so that duplicate carries fields instead of duplicated.
- RestoreRefusedProblem documents reason on a refused file restore.
- FieldProblem.rejected says what it holds: the bound a length or count
  broke, or an externalId outside the allowed characters, and nothing else.

ProblemSchemaParityTest rendered problems without the advice and filter
that add retry and requestId, so it could not see them. It now renders
through both, collects emitted members the same way for the reachability
check, and covers the constraint duplicate and the restore refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Olav Gjerde <olav@intellistream.ai>
@JosteinGj JosteinGj closed this Sep 16, 2026
@JosteinGj
JosteinGj deleted the errors/7-problem-schemas-rebased branch September 16, 2026 08:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants