-
Notifications
You must be signed in to change notification settings - Fork 5
extract reusable rest client and allow greedy router path parsing #3232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
| headers: { | ||
| entries: () => [['Content-Type', 'application/json; charset=utf-8']], | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
headers are required for fetch - https://developer.mozilla.org/en-US/docs/Web/API/Response/headers
and TS types reflect that

so I think it's safe for us to require it in the restclient.
modules/routing/src/logger.ts
Outdated
| } | ||
| if (value instanceof Error) { | ||
| return value.stack ?? ''; | ||
| return (value.stack ?? '') + '\n' + this.objectToPrettyString(value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
previously if we stashed extra fields into an error, they were not logged anywhere e.g. when the router catches an exception.
Since we are now throwing non-2xx as custom errors, this means the logs don't show all the information.
With this change, the full information is available after the stack trace.
| (value.stack ?? '') + | ||
| '\n' + | ||
| this.objectToPrettyString(value) + | ||
| (value.cause ? '\nCaused by: ' + this.prettyPrint(value.cause) : '') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cause doesn't get printed by node's e.stack by default
| return ( | ||
| (value.stack ?? '') + | ||
| '\n' + | ||
| this.objectToPrettyString(value) + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
any extra properties of the error don't get printed by node's e.stack by default
| }; | ||
|
|
||
| /** | ||
| * if routeParts ends with a greedy `+`, batch together the last eventsParts accordingly |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
for some reason, when I try to write algorithmic code in TS it always ends up looking horrible.
ebcd2f2 to
1a23ebc
Compare
1a23ebc to
6061b8d
Compare
| @@ -0,0 +1,184 @@ | |||
| import { logger } from '@modules/routing/logger'; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this file is mostly extracted from zuoraClient.ts
modules/zuora/src/restClient.ts
Outdated
|
|
||
| const json: unknown = JSON.parse(result.responseBody); | ||
| if (!this.isLogicalSuccess || this.isLogicalSuccess(json)) { | ||
| return schema.parse(json); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this throws a ZodError rather than RestClientError - this hasn't changed and isn't causing an issue, as usually we want to fail at that point, but wrapping it in a RestClientError would give us more infomartion about the http response that led to the parsing error
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure that isLogicalSuccess is meaningful in a general rest context, it would probably be better to just check the http code and return the result, then the calling code can take care of more domain specific checks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah true I was just avoiding having an implementation of get, put, post etc in zuoraclient, it can just have one check to cover all methods. Will think about it again as I'm sure there's a better way and I agree this way is a bit odd.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just catching up on this, I've made it less intrusive by having a single assertValidResponse fire and forget method that does everything, rather than having two separate methods tangled into the logic. But I don't think there's a way to move it up a level without having a get/put/post etc method in ZuoraClient (which wouldn't be out of the question if we still prefer it)
This is the same situation we had with the scala version, the RestRequestMaker class called jsonIsSuccessful before parsing the json
support-service-lambdas/lib/restHttp/src/main/scala/com/gu/util/resthttp/RestRequestMaker.scala
Line 163 in f89ca55
| _ <- if (skipCheck == WithoutCheck) ClientSuccess(()) else jsonIsSuccessful(bodyAsJson) |
| public async getRaw(path: string): Promise<RestResult> { | ||
| return await this.fetchRawBody(logger.getCallerInfo(1))(path, 'GET'); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
getRaw didn't used to exist, but it will be useful when reading from non-json endpoints i.e. proxying sensitive versions of things like https://user-benefits.code.dev-guardianapis.com/benefits/list
| path: string, | ||
| method: string, | ||
| schema: T, | ||
| body?: string, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like body should be an object (possibly with a schema to validate it) rather than expecting the caller to stringify it every time, but that hasn't changed and won't in this PR.
| * Provide any Authorization headers via this method. They will be sent but not logged. | ||
| * @protected | ||
| */ | ||
| protected abstract getAuthHeaders: () => Promise<Record<string, string>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like zuora client uses BearerTokenProvider, not sure if that should have been used by everything that uses oauth, if so we should let people just pass in the client id/secret+URL and handle it all for free.
| it('should handle paths with leading slash', async () => { | ||
| const schema = z.object({ data: z.string() }); | ||
|
|
||
| mockFetchResponse({ | ||
| ok: true, | ||
| status: 200, | ||
| body: { data: 'test' }, | ||
| headers: {}, | ||
| }); | ||
|
|
||
| await client.get('/path/to/resource', schema); | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledWith( | ||
| `${mockBaseUrl}/path/to/resource`, | ||
| expect.any(Object), | ||
| ); | ||
| }); | ||
|
|
||
| it('should handle paths without leading slash', async () => { | ||
| const schema = z.object({ data: z.string() }); | ||
|
|
||
| mockFetchResponse({ | ||
| ok: true, | ||
| status: 200, | ||
| body: { data: 'test' }, | ||
| headers: {}, | ||
| }); | ||
|
|
||
| await client.get('path/to/resource', schema); | ||
|
|
||
| expect(fetchMock).toHaveBeenCalledWith( | ||
| `${mockBaseUrl}/path/to/resource`, | ||
| expect.any(Object), | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the existing zuora client allows both with and without a slash, I feel like we should just pick one and stick with it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR extracts a reusable RestClient base class to standardize HTTP client implementations across the codebase and adds support for greedy path parameters in the router. The changes refactor the existing ZuoraClient to use the new base class and improve logging of errors with causes.
Key changes:
- Introduces a new
RestClientabstract base class with standardized request/response handling, logging, and error generation - Refactors
ZuoraClientto extendRestClient, reducing duplication and improving consistency - Adds router support for greedy path parameters (e.g.,
{path+}) to handle variable-length path segments
Reviewed Changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/zuora/src/restClient.ts | New reusable REST client base class with typed HTTP methods and error handling |
| modules/zuora/src/zuoraClient.ts | Refactored to extend RestClient, implementing custom error generation and auth headers |
| modules/zuora/test/restClient.test.ts | Comprehensive test coverage for the new RestClient base class |
| modules/zuora/test/zuoraClient.test.ts | Updated mock responses to use text() instead of json() for consistency |
| modules/zuora/test/mocks/mockZuoraClient.ts | Updated to reference restServerUrl instead of zuoraServerUrl |
| modules/routing/src/router.ts | Added zipRouteWithEventPath function to support greedy path parameters |
| modules/routing/test/zipRouteWithEventPath.test.ts | Test coverage for greedy path parameter handling |
| modules/routing/src/logger.ts | Enhanced error logging to include error causes and extracted object formatting |
| modules/routing/test/logger.test.ts | Updated tests for improved error logging with causes |
| handlers/product-switch-api/test/amendments.test.ts | Updated mock response to use text() method |
modules/zuora/src/restClient.ts
Outdated
|
|
||
| const json: unknown = JSON.parse(result.responseBody); | ||
| if (!this.isLogicalSuccess || this.isLogicalSuccess(json)) { | ||
| return schema.parse(json); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure that isLogicalSuccess is meaningful in a general rest context, it would probably be better to just check the http code and return the result, then the calling code can take care of more domain specific checks.
| import { zuoraServerUrl } from './utils'; | ||
|
|
||
| export class ZuoraClient { | ||
| export class ZuoraClient extends RestClient { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might it be better to just pass a RestClient in to ZuoraClient rather than using inheritance?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yeah will do, that will hide implementation details more effectively and be easier to follow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actually we can't unless we want to have "passthrough" get/put/post/etc methods on ZuoraClient, which I would prefer to avoid if we can just implement them once only.
840787c to
670ea8d
Compare
| path?: z.Schema<TPath, ZodTypeDef, unknown>; | ||
| body?: z.Schema<TBody, ZodTypeDef, unknown>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this means that we can use .transform on the schema e.g. if we want to turn string into UUID or something
| // Fallback: unknown error format | ||
| const statusText = response.statusText || 'Zuora API Error'; | ||
| return new ZuoraError(statusText, response.status, []); | ||
| const statusText = 'Zuora API Error'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there was a need for the status text, it was added in the original PR
https://github.com/guardian/support-service-lambdas/pull/2117/files#diff-dd8bf5368c55cff37954c8734389b29820f7baf1d164604d874cce76a5e78306R56
and it should always be standard text for the error code.
670ea8d to
dc5cf38
Compare
dc5cf38 to
c1b54e2
Compare
| // Fallback: unknown error format | ||
| const statusText = response.statusText || 'Zuora API Error'; | ||
| return new ZuoraError(statusText, response.status, []); | ||
| return new ZuoraError('Zuora API Error', response.statusCode, []); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there was a need for the status text, it was added in the original PR
https://github.com/guardian/support-service-lambdas/pull/2117/files#diff-dd8bf5368c55cff37954c8734389b29820f7baf1d164604d874cce76a5e78306R56
and it should always be standard text for the error code.
| if (result.statusCode === 429) { | ||
| logger.log( | ||
| `Received a 429 rate limit response with response headers ${JSON.stringify(result.responseHeaders)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've kept this here but in reality all the headers are extracted into the RestClientError and will be logged by the router, so this may be redundant.
|
|
||
| super('stage' as Stage, mockTokenProvider); | ||
| this.zuoraServerUrl = 'https://mock.zuora.com'; | ||
| // @ts-expect-error override for the test |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
didn't want to make the field public just for the test
This PR is a pre-refactoring to #3141
(via #3134 although that doesn't depend on this)
Background
There is no standard rest client at the moment, meaning that for each system we access (e.g. SF, Zuora, Mparticle) we have to
In reality, it looks like we aren't consistent about those things across the clients.
This PR
Trybecause I couldn't see the wood for the trees when doing zuoraclient error handling.Tested in CODE, looks ok via discount-api (includes zuora GET, PUT and POST)
https://eu-west-1.console.aws.amazon.com/cloudwatch/home?region=eu-west-1#logsV2:log-groups/log-group/$252Faws$252Flambda$252Fdiscount-api-CODE/log-events/2025$252F11$252F19$252F$255B$2524LATEST$255D99fa80e816244237af0d7d3a1a31ec86
Future work