Skip to content

Commit 29397da

Browse files
committed
fix: add external redirect handling
1 parent 3f9fb16 commit 29397da

File tree

2 files changed

+211
-76
lines changed

2 files changed

+211
-76
lines changed

docs/interfaces/openapi_client.CommonHttpClientOptions.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Options for the common HTTP client.
1515
- [binaryResponseType](openapi_client.CommonHttpClientOptions.md#binaryresponsetype)
1616
- [deprecatedOperations](openapi_client.CommonHttpClientOptions.md#deprecatedoperations)
1717
- [errorClass](openapi_client.CommonHttpClientOptions.md#errorclass)
18+
- [externalFetch](openapi_client.CommonHttpClientOptions.md#externalfetch)
1819
- [fetch](openapi_client.CommonHttpClientOptions.md#fetch)
1920
- [followRedirects](openapi_client.CommonHttpClientOptions.md#followredirects)
2021
- [formatHttpErrorMessage](openapi_client.CommonHttpClientOptions.md#formathttperrormessage)
@@ -89,6 +90,29 @@ Error class to be thrown when an error occurs.
8990

9091
___
9192

93+
### externalFetch
94+
95+
`Optional` **externalFetch**: (`url`: `URL`, `request`: [`CommonHttpClientFetchRequest`](openapi_client.CommonHttpClientFetchRequest.md)) => `Promise`\<[`CommonHttpClientFetchResponse`](openapi_client.CommonHttpClientFetchResponse.md)\>
96+
97+
#### Type declaration
98+
99+
▸ (`url`, `request`): `Promise`\<[`CommonHttpClientFetchResponse`](openapi_client.CommonHttpClientFetchResponse.md)\>
100+
101+
External fetch method. Will be used for external redirects.
102+
103+
##### Parameters
104+
105+
| Name | Type |
106+
| :------ | :------ |
107+
| `url` | `URL` |
108+
| `request` | [`CommonHttpClientFetchRequest`](openapi_client.CommonHttpClientFetchRequest.md) |
109+
110+
##### Returns
111+
112+
`Promise`\<[`CommonHttpClientFetchResponse`](openapi_client.CommonHttpClientFetchResponse.md)\>
113+
114+
___
115+
92116
### fetch
93117

94118
`Optional` **fetch**: (`url`: `URL`, `request`: [`CommonHttpClientFetchRequest`](openapi_client.CommonHttpClientFetchRequest.md)) => `Promise`\<[`CommonHttpClientFetchResponse`](openapi_client.CommonHttpClientFetchResponse.md)\>
@@ -114,9 +138,9 @@ ___
114138

115139
### followRedirects
116140

117-
`Optional` **followRedirects**: `boolean`
141+
`Optional` **followRedirects**: `boolean` \| (`params`: \{ `request`: [`CommonHttpClientFetchRequest`](openapi_client.CommonHttpClientFetchRequest.md) ; `response`: [`CommonHttpClientFetchResponse`](openapi_client.CommonHttpClientFetchResponse.md) ; `url`: `URL` }) => `Promise`\<\{ `error?`: `Error` ; `type`: ``"error"`` } \| \{ `response`: [`CommonHttpClientFetchResponse`](openapi_client.CommonHttpClientFetchResponse.md) ; `type`: ``"response"`` } \| \{ `request?`: [`CommonHttpClientFetchRequest`](openapi_client.CommonHttpClientFetchRequest.md) ; `type`: ``"redirect"`` } \| \{ `request?`: [`CommonHttpClientFetchRequest`](openapi_client.CommonHttpClientFetchRequest.md) ; `type`: ``"externalRedirect"`` }\>
118142

119-
Whether to follow redirects. Default is true.
143+
Whether to follow redirects. Default is true. Can also be a function that decides what to do on a redirect.
120144

121145
___
122146

src/schema-to-typescript/common/core/common-http-client.ts

Lines changed: 185 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,36 @@ export interface CommonHttpClientOptions {
8080
*/
8181
processError?: (error: Error) => Error;
8282
/**
83-
* Whether to follow redirects. Default is true.
83+
* External fetch method. Will be used for external redirects.
8484
*/
85-
followRedirects?: boolean;
85+
externalFetch?: (url: URL, request: CommonHttpClientFetchRequest) => Promise<CommonHttpClientFetchResponse>;
86+
/**
87+
* Whether to follow redirects. Default is true. Can also be a function that decides what to do on a redirect.
88+
*/
89+
followRedirects?:
90+
| boolean
91+
| ((params: {
92+
url: URL;
93+
request: CommonHttpClientFetchRequest;
94+
response: CommonHttpClientFetchResponse;
95+
}) => Promise<
96+
| {
97+
type: 'error';
98+
error?: Error;
99+
}
100+
| {
101+
type: 'response';
102+
response: CommonHttpClientFetchResponse;
103+
}
104+
| {
105+
type: 'redirect';
106+
request?: CommonHttpClientFetchRequest;
107+
}
108+
| {
109+
type: 'externalRedirect';
110+
request?: CommonHttpClientFetchRequest;
111+
}
112+
>);
86113
}
87114

88115
/**
@@ -643,6 +670,59 @@ const formatParameter: Record<CommonHttpClientRequestParameterSerializeStyle, Pa
643670
*/
644671
const deprecationWarningShown: {[methodAndPath: string]: boolean} = {};
645672

673+
/**
674+
* Default implementation of the redirect handler.
675+
*/
676+
const defaultRedirectHandler: Exclude<CommonHttpClientOptions['followRedirects'], boolean | undefined> = async ({
677+
url,
678+
response
679+
}: {
680+
url: URL;
681+
response: CommonHttpClientFetchResponse;
682+
}) => {
683+
const redirectUrl = new URL(response.headers['location'], url);
684+
let responseUrl;
685+
try {
686+
responseUrl = new URL(response.url);
687+
} catch (e) {
688+
responseUrl = url;
689+
}
690+
691+
if (responseUrl.host !== redirectUrl.host) {
692+
return {type: 'externalRedirect'};
693+
} else {
694+
return {type: 'redirect'};
695+
}
696+
};
697+
698+
/**
699+
* Default fetch implementation.
700+
*/
701+
async function defaultFetch(url: URL, request: CommonHttpClientFetchRequest): Promise<CommonHttpClientFetchResponse> {
702+
const {...requestProps} = request;
703+
const requestInit: RequestInit = requestProps;
704+
const response = await fetch(url, requestInit);
705+
const body: CommonHttpClientFetchResponseBody = isJsonMediaType(response.headers.get('content-type') ?? '')
706+
? {type: 'json', data: await response.json()}
707+
: {type: 'blob', data: await response.blob()};
708+
const headers: CommonHttpClientResponseHeaders = {};
709+
response.headers.forEach((value, key) => {
710+
headers[key] = value;
711+
});
712+
if (response.headers.has('set-cookie') && 'getSetCookie' in response.headers) {
713+
headers['set-cookie'] = (response.headers as {getSetCookie(): string[]}).getSetCookie();
714+
}
715+
return {
716+
status: response.status,
717+
statusText: response.statusText,
718+
body,
719+
url: response.url,
720+
headers,
721+
ok: response.ok,
722+
customRequestProps: request.customRequestProps
723+
};
724+
}
725+
646726
/**
647727
* Common HTTP client. Configurable for different environments.
648728
*/
@@ -749,32 +829,77 @@ export class CommonHttpClient {
749829
return url;
750830
}
751831

752-
/**
753-
* Default fetch implementation.
754-
*/
755-
protected async fetch(url: URL, request: CommonHttpClientFetchRequest): Promise<CommonHttpClientFetchResponse> {
756-
const {...requestProps} = request;
757-
const requestInit: RequestInit = requestProps;
758-
const response = await fetch(url, requestInit);
759-
const body: CommonHttpClientFetchResponseBody = isJsonMediaType(response.headers.get('content-type') ?? '')
760-
? {type: 'json', data: await response.json()}
761-
: {type: 'blob', data: await response.blob()};
762-
const headers: CommonHttpClientResponseHeaders = {};
763-
response.headers.forEach((value, key) => {
764-
headers[key] = value;
765-
});
766-
if (response.headers.has('set-cookie') && 'getSetCookie' in response.headers) {
767-
headers['set-cookie'] = (response.headers as {getSetCookie(): string[]}).getSetCookie();
832+
protected processErrorIfNecessary(error: unknown) {
833+
if (this.options.processError) {
834+
return this.options.processError(error instanceof Error ? error : new Error(String(error)));
835+
}
836+
return error;
837+
}
838+
839+
protected async handleRedirect(error: CommonHttpClientError) {
840+
if (this.options.followRedirects === false) {
841+
throw this.processErrorIfNecessary(error);
842+
}
843+
844+
const {request, response, url} = error;
845+
846+
if (!request || !response) {
847+
throw this.processErrorIfNecessary(error);
848+
}
849+
850+
if (response.status <= 300 || response.status >= 400 || !response.headers['location']) {
851+
throw this.processErrorIfNecessary(error);
852+
}
853+
854+
const redirectHandler =
855+
typeof this.options.followRedirects === 'function' ? this.options.followRedirects : defaultRedirectHandler;
856+
857+
const action = await redirectHandler({url, request, response});
858+
859+
if (!action || !('type' in action)) {
860+
error.message = `Invalid redirect handler result for ${error.message}.`;
861+
throw this.processErrorIfNecessary(error);
862+
}
863+
864+
const redirectPreservingMethod = response.status === 307 || response.status === 308;
865+
const newUrl = new URL(response.headers['location'], url);
866+
867+
if (action.type === 'error') {
868+
error.message = `Redirect to ${newUrl.toString()} not allowed by redirect handler. ${error.message}`;
869+
throw this.processErrorIfNecessary(action.error ?? error);
870+
} else if (action.type === 'response') {
871+
return action.response;
872+
} else if (action.type === 'redirect') {
873+
const fetchRequest =
874+
action.request ??
875+
(await this.generateFetchRequest({
876+
path: newUrl.pathname,
877+
method: redirectPreservingMethod ? request.method : 'GET'
878+
}));
879+
return this.performFetchRequest(newUrl, fetchRequest, this.options.fetch ?? defaultFetch).catch((error) =>
880+
this.handleRequestError(error)
881+
);
882+
} else if (action.type === 'externalRedirect') {
883+
const fetchRequest = action.request ?? {
884+
// Change method to GET for 301, 302, 303 redirects
885+
method: redirectPreservingMethod ? request.method : 'GET',
886+
headers: {},
887+
cache: request.cache,
888+
credentials: request.credentials,
889+
redirect: 'error'
890+
};
891+
return this.performFetchRequest(newUrl, fetchRequest, this.options.externalFetch ?? defaultFetch).catch(
892+
(error) => this.handleRequestError(error)
893+
);
894+
} else {
895+
error.message = `Invalid redirect handler result for ${error.message}.`;
896+
throw this.processErrorIfNecessary(error);
768897
}
769-
return {
770-
status: response.status,
771-
statusText: response.statusText,
772-
body,
773-
url: response.url,
774-
headers,
775-
ok: response.ok,
776-
customRequestProps: request.customRequestProps
777-
};
898+
}
899+
900+
protected handleRequestError(e: unknown): never | Promise<CommonHttpClientFetchResponse> {
901+
const error = e as CommonHttpClientError;
902+
return this.handleRedirect(error);
778903
}
779904

780905
/**
@@ -784,37 +909,11 @@ export class CommonHttpClient {
784909
try {
785910
return await this.performRequest(request);
786911
} catch (e) {
787-
const error = e as CommonHttpClientError;
788-
if (error.response) {
789-
if (
790-
error.response.status > 300 &&
791-
error.response.status < 400 &&
792-
error.response.headers['location'] &&
793-
this.options.followRedirects !== false
794-
) {
795-
const redirectUrl = new URL(error.response.headers['location'], error.url);
796-
return this.request({
797-
method: error.response.status === 307 || error.response.status === 308 ? request.method : 'GET',
798-
path: redirectUrl.pathname,
799-
query:
800-
redirectUrl.searchParams.size > 0
801-
? Object.fromEntries(redirectUrl.searchParams.entries())
802-
: undefined
803-
});
804-
}
805-
}
806-
if (this.options.processError) {
807-
throw this.options.processError(e instanceof Error ? e : new Error(String(e)));
808-
}
809-
throw e;
912+
return await this.handleRequestError(e);
810913
}
811914
}
812915

813-
/**
814-
* Perform a request.
815-
*/
816-
protected async performRequest(request: CommonHttpClientRequest): Promise<CommonHttpClientFetchResponse> {
817-
this.logDeprecationWarningIfNecessary(request);
916+
protected async generateFetchRequest(request: CommonHttpClientRequest): Promise<CommonHttpClientFetchRequest> {
818917
try {
819918
request = await this.preprocessRequest(request);
820919
} catch (e) {
@@ -838,18 +937,6 @@ export class CommonHttpClient {
838937
`preprocessRequest error: ${getErrorMessage(e)}`
839938
);
840939
}
841-
let url;
842-
try {
843-
url = this.buildUrl(request);
844-
} catch (e) {
845-
throw new this.options.errorClass(
846-
new URL(request.path, this.options.baseUrl),
847-
undefined,
848-
undefined,
849-
this.options,
850-
`Error building request URL: ${getErrorMessage(e)}`
851-
);
852-
}
853940
const {
854941
body,
855942
path: _path,
@@ -861,24 +948,27 @@ export class CommonHttpClient {
861948
...otherRequestProps
862949
} = request;
863950
const headers = this.cleanupHeaders(requestHeaders);
864-
const fetchRequest: CommonHttpClientFetchRequest = {
951+
return {
865952
...otherRequestProps,
866953
headers,
867954
cache: cache ?? 'default',
868955
credentials: credentials ?? 'same-origin',
869956
redirect: 'error',
870957
body: this.getRequestBody(request)
871958
};
959+
}
960+
961+
protected async performFetchRequest(
962+
url: URL,
963+
fetchRequest: CommonHttpClientFetchRequest,
964+
fetchMethod: (url: URL, request: CommonHttpClientFetchRequest) => Promise<CommonHttpClientFetchResponse>
965+
): Promise<CommonHttpClientFetchResponse> {
872966
let attemptNumber = 1;
873967
for (;;) {
874968
try {
875969
let fetchResponse: CommonHttpClientFetchResponse;
876970
try {
877-
if (this.options.fetch) {
878-
fetchResponse = await this.options.fetch(url, fetchRequest);
879-
} else {
880-
fetchResponse = await this.fetch(url, fetchRequest);
881-
}
971+
fetchResponse = await fetchMethod(url, fetchRequest);
882972
} catch (e) {
883973
throw new this.options.errorClass(url, fetchRequest, undefined, this.options, getErrorMessage(e));
884974
}
@@ -903,7 +993,7 @@ export class CommonHttpClient {
903993
this.options,
904994
this.options.formatHttpErrorMessage
905995
? this.options.formatHttpErrorMessage(fetchResponse, fetchRequest)
906-
: `HTTP Error ${request.method} ${url.toString()} ${fetchResponse.status} (${fetchResponse.statusText})`
996+
: `HTTP Error ${fetchRequest.method} ${url.toString()} ${fetchResponse.status} (${fetchResponse.statusText})`
907997
);
908998
}
909999
return fetchResponse;
@@ -916,6 +1006,27 @@ export class CommonHttpClient {
9161006
}
9171007
}
9181008

1009+
/**
1010+
* Perform a request.
1011+
*/
1012+
protected async performRequest(request: CommonHttpClientRequest): Promise<CommonHttpClientFetchResponse> {
1013+
this.logDeprecationWarningIfNecessary(request);
1014+
const fetchRequest = await this.generateFetchRequest(request);
1015+
let url;
1016+
try {
1017+
url = this.buildUrl(request);
1018+
} catch (e) {
1019+
throw new this.options.errorClass(
1020+
new URL(request.path, this.options.baseUrl),
1021+
undefined,
1022+
undefined,
1023+
this.options,
1024+
`Error building request URL: ${getErrorMessage(e)}`
1025+
);
1026+
}
1027+
return this.performFetchRequest(url, fetchRequest, this.options.fetch ?? defaultFetch);
1028+
}
1029+
9191030
/**
9201031
* Post-process the response.
9211032
*/

0 commit comments

Comments
 (0)