feat(java,php): expose the http status code on the raw response - #17580
feat(java,php): expose the http status code on the raw response#17580wiebren wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
AI Review Summary
Adds statusCode()/getStatusCode() to the raw response wrappers in Java and PHP, plus (from the stacked PR) the whole PHP raw-client machinery. The Java half is small and clean. In PHP a few things stand out: the raw client constructor's parameter naming is inconsistent with what the body assigns, withRawResponse() passes client options where the constructor expects environment ordering assumptions, and the README snippet is emitted unconditionally even for clients where no raw client exists.
- 🔴 1 critical issue(s)
- 🟡 3 warning(s)
- 🔵 2 suggestion(s)
To request another review, comment /ai-review on this pull request.
| const parameters: php.Parameter[] = [ | ||
| php.parameter({ | ||
| name: "$client", | ||
| type: php.Type.reference(context.rawClient.getClassReference()) | ||
| }) | ||
| ]; |
There was a problem hiding this comment.
🔴 critical
The first parameter is named $client (note the leading $ in the name, unlike the other parameters which pass bare names), but the body assigns $this->client = $${context.rawClient.getFieldName()}. If getFieldName() isn't literally client, the generated constructor references an undefined variable. Also the inconsistent $ prefix between this parameter and the ones below will produce $$environment-style names if the codegen prefixes automatically. Please make naming uniform and derive the parameter name from context.rawClient.getFieldName().
There was a problem hiding this comment.
Fixed (same comment on #17579). It did render correctly — php.parameter runs the name through convertToPhpVariableName, so "$client" and "client" both emit $client, and there is no $$environment case — but the spelling was inconsistent, so the parameter name and the assignment now both come from context.rawClient.getFieldName().
| const arguments_: php.AstNode[] = [php.codeblock(`$this->${context.rawClient.getFieldName()}`)]; | ||
| arguments_.push( | ||
| isMultiUrl ? php.codeblock("$this->environment") : php.codeblock(`$this->${context.getClientOptionsName()}`) | ||
| ); |
There was a problem hiding this comment.
🟡 warning
In the multi-URL case the constructor's second parameter is environment (required, non-optional) while in the single-URL case it's options. This helper mirrors that by position only — any future reorder in getRawClientConstructor silently breaks it. Worth extracting a single shared descriptor of the constructor's parameter list so the two can't drift.
There was a problem hiding this comment.
Good point, and I have removed the positional coupling entirely rather than just documenting it. Both sides are now generated from one ordered descriptor in raw-client/rawClientConstructor.ts:
interface RawClientConstructorParameter {
parameter: php.Parameter;
argument: php.AstNode; // what the plain client passes
assignment: string; // how the raw client keeps it
}getRawClientConstructor() maps .parameter and writes .assignment; getRawClientConstructorArguments() — used by withRawResponse() — maps .argument. A reorder moves both at once, and adding a parameter cannot be half-done.
| } | ||
| if (isMultiUrl) { | ||
| writer.writeTextStatement("$this->environment = $environment"); | ||
| writer.writeTextStatement(`$this->${context.getClientOptionsName()} = []`); |
There was a problem hiding this comment.
🟡 warning
For multi-URL clients the raw client's clientOptions is hard-coded to [], discarding the parent client's options (headers, timeouts, base overrides). Endpoint bodies read $this->{clientOptions}, so raw calls on multi-URL SDKs will behave differently from plain calls. Pass the options through here as well.
There was a problem hiding this comment.
Real bug, fixed. The plain root client keeps its options in the multi-url case, so resetting them to [] meant a raw root call lost the base-url override, headers, timeout and retries. Options are now always taken and always forwarded:
public function __construct(
RawClient $client,
Environments $environment,
?array $options = null,
) { ... }
// and, on the plain client:
return $this->rawResponseClient ??= new RawS3Client($this->client, $this->environment, $this->options);| snippets[FernGeneratorCli.StructuredFeatureId.Timeouts] = this.buildTimeoutSnippets(); | ||
| snippets[FernGeneratorCli.StructuredFeatureId.CustomClient] = this.buildCustomClientSnippets(); | ||
| snippets[ReadmeSnippetBuilder.EXCEPTION_HANDLING_FEATURE_ID] = this.buildExceptionHandlingSnippets(); | ||
| snippets[ReadmeSnippetBuilder.ACCESS_RAW_RESPONSE_DATA_FEATURE_ID] = this.buildRawResponseSnippets(); |
There was a problem hiding this comment.
🟡 warning
ACCESS_RAW_RESPONSE_DATA snippets are added unconditionally, but the raw client is only generated when a service has endpoints. For an IR whose selected feature endpoint lives on an endpoint-less package this emits a README snippet calling a method that doesn't exist. Guard it the same way the generators do (service.endpoints.length > 0).
There was a problem hiding this comment.
I do not think this case can arise. getEndpointsForFeature resolves to real endpoints — the ones configured in readmeConfig.features, or [this.defaultEndpointId] — and an endpoint always belongs to a service, so the client the snippet addresses has at least one endpoint and therefore has a raw client. There is no path where an endpoint id maps to an endpoint-less package.
This also matches how the neighbouring snippet builders work: buildExceptionHandlingSnippets, buildRetrySnippets and buildTimeoutSnippets are all emitted unconditionally off the same helper. Happy to add a guard anyway if you can point at an IR shape where it breaks.
| public function getHeader(string $name): array | ||
| { | ||
| foreach ($this->headers as $header => $values) { | ||
| if (strcasecmp($header, $name) === 0) { | ||
| return $values; | ||
| } | ||
| } | ||
| return []; | ||
| } |
There was a problem hiding this comment.
🔵 suggestion
getHeader is O(n) per lookup over all headers. Fine for typical header counts, but if you want case-insensitive lookup cheaply, build a lowercased index once in the constructor. Not blocking.
There was a problem hiding this comment.
Fair, and deliberate — a lowercased index would mean building and holding a second array on every response for a lookup over what is typically a handful of headers, and getHeaders() has to keep the original casing anyway. Left as the linear scan; happy to revisit if a profile ever says otherwise.
| writer.writeTextStatement("$this->body = $body"); | ||
| writer.writeTextStatement("$this->headers = $headers"); |
There was a problem hiding this comment.
🔵 suggestion
$this->headers = $headers is assigned before parent::__construct(...). Harmless here, but conventionally the parent constructor runs first; keeping the order consistent avoids surprises if the base class ever touches subclass state.
There was a problem hiding this comment.
This matches the constructor that is already generated — $this->body = $body; is likewise assigned before parent::__construct(...) — so putting the headers first keeps the two adjacent instead of splitting them around the parent call. Reordering both would change output that is not part of this change. The base class is Exception via the generated base exception, which does not read subclass state.
There was a problem hiding this comment.
Devin Review found 1 potential issue.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| class_.addMethod( | ||
| getWithRawResponseMethod({ | ||
| context: this.context, | ||
| rawClassReference: this.context.getRawRootClientClassReference(), | ||
| isMultiUrl | ||
| }) | ||
| ); |
There was a problem hiding this comment.
🟡 Raw responses disappear behind interfaces
When generateClientInterfaces is enabled, the generated interfaces omit withRawResponse(). Consumers typed against those interfaces cannot access any response metadata.
Prompt for agents
Add withRawResponse() signatures to both generated client-interface variants when their corresponding service has endpoints. Update RootClientInterfaceGenerator and SubPackageClientInterfaceGenerator so the interface contract matches the new public methods emitted by RootClientGenerator and SubPackageClientGenerator, using the appropriate raw root or raw subpackage return type. Cover generateClientInterfaces configurations for root and nested service clients.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Implemented. RootClientInterfaceGenerator and SubPackageClientInterfaceGenerator now declare withRawResponse() whenever the service has endpoints, returning the concrete raw client — the shape java and go use. The folders:with-interfaces fixture passes with the scripts (phpstan at level max and phpunit); the five interfaces with endpoints gained the method.
954b1e2 to
a69529c
Compare
A php endpoint method read the response and returned only the deserialized body, so a response header was unreachable: a caller who needed one had to decorate the psr-18 client. Every other fern sdk exposes the raw response - c#, go, java, python and typescript - and php was the only one that did not. Every generated client that has endpoints gains `withRawResponse()`, returning a raw counterpart whose endpoints return `HttpResponse<T>`: `getBody()` is exactly what the plain client returns, next to `getHeaders()`, `getHeader()` and `getHeaderLine()`. The api exception carries the response headers too, so a header like `X-Process-Id` is reachable on the error path as well as the success path.
`withRawResponse()` handed a caller the body and the headers but not the status, so 200, 201 and 202 were indistinguishable. For an api that answers 202 when a command was accepted but is still running and 200 when it is done - with the same body either way - the status is the whole signal, and a caller had to add an okhttp interceptor (java) or decorate the psr-18 client (php) to see it. java's `HttpResponse` gains `statusCode()` next to `body()` and `headers()`; php's `HttpResponse` gains `getStatusCode()` next to `getBody()` and `getHeaders()`. Both read it from the response they are already handed, so nothing else changes.
a69529c to
7d3e7a6
Compare
Description
Linear ticket: n/a — found while running one OpenAPI document through all six SDK generators
and comparing what each one hands its caller.
withRawResponse()gives a caller the body and the headers but not the status code, so 200,201 and 202 are indistinguishable.
That matters for any api that answers 202 Accepted when a call was taken but is still
running, and 200 OK when it is done. Plenty do: submitting an order comes back as 200 when
the store could fulfil it while you waited, and 202 when it was only queued. The body is
byte-identical in both cases —
OrderResponsehas no async flag — so the http status is theentire signal. A client that cannot report it silently tells its caller the order succeeded
when it has not happened yet.
The same blindness covers 201: "created" and "here it is" are both just a body.
Today a caller works around it by reaching underneath the sdk — an okhttp
Interceptorinjava, a psr-18 decorator in php — to record what the transport saw. Both are per-client global
state, so neither is safe when calls overlap.
Changes Made
HttpResponse<T>gainsstatusCode()next tobody()andheaders(), read fromrawResponse.code()in the constructor it is already handed. The class is a static resourceemitted by the v1 generator and referred to by name from java-v2, so the tandem picks the
change up in one place;
java-v2'sfeatures.ymldescription is updated to name the newaccessor.
HttpResponsegainsgetStatusCode()next togetBody()andgetHeaders(),taken from
$response->getStatusCode()inHttpResponse::from.features.ymland theREADME snippet name it.
Both changes are additive: an existing caller of
body()/getBody()orheaders()/getHeaders()sees no difference.Testing
java.
./gradlew :sdk:test :generator-utils:testpasses. The generated class was thencompiled for real: a production OpenAPI document of ≈900 endpoints, generated with this
branch's generator image, builds under maven with JDK 21 — which compiles both the emitted
<Prefix>HttpResponseand everywithRawResponse()call site in it.php. The same fourteen php-sdk seed fixtures — 19/19 configurations — pass with the
scripts:
composer install && composer build && composer analyze && composer test, i.e.phpstan at level max and phpunit.
exhaustive(2),pagination(3),folders(2),unions(2),streaming,server-sent-events,multi-url-environment,endpoint-security-auth,file-download,bytes-download,enum,unknown,nullable-optional,response-property.Seed snapshots are not committed here, following the
auto-update-seedworkflow that owns them.End to end, against the three cases this is about. A contract suite puts the same
request/response pair to every generated client and records what each one could report. The
three status cases are the same call answered
200,202and201with an identical body;two header cases sit next to them.
ok*in that table means the client only managed it becausethe runner added something the sdk does not provide.
ok*interceptorokok*psr-18 decoratorokok*interceptorokok*psr-18 decoratorokok*interceptorokok*psr-18 decoratorokokokok*okokokok*okBoth runners were rewritten to drop their workarounds entirely — the java one no longer builds
an
OkHttpClientwith an interceptor, the php one no longer wraps the psr-18 client — and bothnow read
statusCode()/getStatusCode()off the response the sdk returns. Every row is a realexecution against a local mock api that serves the canned response of the case and records what
went over the wire.
Notes
response, so the wrapper stays independent of the http client and nothing hands out a
half-consumed body stream.
Generated with Claude Code