Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this changes
How it was verified
Checklist
git commit -s), per CONTRIBUTING.md./gradlew buildpasses(see AGENTS.md for which of the two documentation sites it belongs in)