diff --git a/services/http.ts b/services/http.ts index 3e818b4..57791e2 100644 --- a/services/http.ts +++ b/services/http.ts @@ -1,3 +1,9 @@ +export type FetchConfig = Omit & { + headers?: Record; +}; + +export type HttpBody = BodyInit | object; + export class BaseHttpClientError extends Error { response: Response; @@ -25,59 +31,66 @@ export abstract class BaseHttpClient { return this._baseUrl + rest } - _get(url: string, config?: object): Promise { + _get(url: string, config?: FetchConfig): Promise { return this._send(url, 'GET', undefined, config); } - _post(url: string, body?: any, config?: object): Promise { + _post(url: string, body?: HttpBody, config?: FetchConfig): Promise { return this._send(url, 'POST', body, config); } - _put(url: string, body?: any, config?: object): Promise { + _put(url: string, body?: HttpBody, config?: FetchConfig): Promise { return this._send(url, 'PUT', body, config); } - _patch(url: string, body?: any, config?: object): Promise { + _patch(url: string, body?: HttpBody, config?: FetchConfig): Promise { return this._send(url, 'PATCH', body, config); } - _delete(url: string, config?: object): Promise { + _delete(url: string, config?: FetchConfig): Promise { return this._send(url, 'DELETE', undefined, config); } - async _sendTest(url: string, method: string, body?: any): Promise { - const response = await fetch(this.url(url), { - method, - body, - headers: { - 'Accept': 'application/text', - 'Authorization': this._requestHeaders.Authorization - } - }); - - if (!response.ok) { - throw new BaseHttpClientError(response); - } - - return response; - } - - async _send(url: string, method: string, body?: any, config?: object): Promise { + async _send( + url: string, + method: string, + body?: HttpBody, + config?: FetchConfig, + ): Promise { + const { headers: configHeaders, ...restConfig } = config ?? {}; + const mergedHeaders: Record = { + ...this._requestHeaders, + ...configHeaders, + }; const requestOptions = { method, - body, - headers: this._requestHeaders, + body: undefined as BodyInit | undefined, + headers: mergedHeaders, signal: this._abortSignal, - ...config + ...restConfig, }; - if (requestOptions.headers['Content-Type'] === 'application/json' - && !(body instanceof FormData) - && typeof body !== 'undefined' - && body !== null - ) { + // Let the browser set Content-Type (and multipart boundary) for FormData: + if (body instanceof FormData) { + delete mergedHeaders['Content-Type']; + requestOptions.body = body; + } + else if (body !== null && body !== undefined && ( + mergedHeaders['Content-Type'] === 'application/json' + || ( + typeof body === 'object' + && !(body instanceof Blob) + && !(body instanceof ArrayBuffer) + && !ArrayBuffer.isView(body) + && !(body instanceof URLSearchParams) + && !(body instanceof ReadableStream) + ) + )) { requestOptions.body = JSON.stringify(body); } + else { + requestOptions.body = body as BodyInit; + } const response = await fetch(this.url(url), requestOptions); diff --git a/services/osm.ts b/services/osm.ts index 9f41aea..16f18c4 100644 --- a/services/osm.ts +++ b/services/osm.ts @@ -1,7 +1,12 @@ import parseOsmChangeXml from '@osmcha/osmchange-parser'; import type { FeatureCollection, Point } from 'geojson'; -import { BaseHttpClient, BaseHttpClientError } from "~/services/http"; +import { + BaseHttpClient, + BaseHttpClientError, + type FetchConfig, + type HttpBody, +} from '~/services/http'; import * as xml from '~/util/xml'; import type { ICancelableClient } from '~/services/loading'; @@ -14,7 +19,6 @@ import type { OsmElement, OsmNode, OsmNote, - OsmTags, OsmWay, } from '~/types/osm'; import type { WorkspaceId } from '~/types/workspaces'; @@ -207,7 +211,9 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { this.#webUrl = webUrl; this.#tdeiClient = tdeiClient; this.#setAuthHeader(); - this._requestHeaders['Accept'] = 'text/plain'; + + // OSM API can return XML or JSON based on the header or file extension: + this._requestHeaders['Accept'] = '*/*'; this._requestHeaders['Content-Type'] = 'text/plain'; } @@ -234,8 +240,8 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { display_name: this.auth.displayName }; - await this._put(`user/${this.auth.subject}`, JSON.stringify(body), { - headers: { ...this._requestHeaders, 'Content-Type': 'application/json' } + await this._put(`user/${this.auth.subject}`, body, { + headers: { 'Content-Type': 'application/json' } }); } @@ -286,7 +292,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { ): Promise { const response = await this._get(`${type}/${id}/${version}`, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId, }, @@ -301,7 +306,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { async getNodes(workspaceId: WorkspaceId, nodeIds: (number | string)[]): Promise { const response = await this._get(`nodes?nodes=${nodeIds.join(',')}`, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId, }, @@ -319,7 +323,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { async getWays(workspaceId: WorkspaceId, wayIds: (number | string)[]): Promise { const response = await this._get(`ways?ways=${wayIds.join(',')}`, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId, }, @@ -337,7 +340,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { async getWaysForNode(workspaceId: WorkspaceId, nodeId: number): Promise { const response = await this._get(`node/${nodeId}/ways`, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId, }, @@ -348,7 +350,7 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { async listChangesets(workspaceId: WorkspaceId): Promise { const response = await this._get(`changesets.json`, { - headers: { ...this._requestHeaders, 'X-Workspace': workspaceId }, + headers: { 'X-Workspace': workspaceId }, }); const changesets = (await response.json())?.changesets ?? []; @@ -374,7 +376,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { const response = await this._get(url, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId, }, @@ -401,7 +402,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { { const response = await this._get(`changeset/${changesetId}/download`, { headers: { - ...this._requestHeaders, 'Accept': 'application/xml', 'X-Workspace': workspaceId, }, @@ -427,7 +427,7 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { const body = xml.serialize(doc); const response = await this._put('changeset/create', body, { - headers: { ...this._requestHeaders, 'X-Workspace': workspaceId }, + headers: { 'X-Workspace': workspaceId }, }); return Number(await response.text()); @@ -441,7 +441,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { await this._post(`changeset/${changesetId}/upload`, changesetXml, { headers: { 'Content-Type': 'application/xml', - 'Authorization': this._requestHeaders['Authorization'], 'X-Workspace': workspaceId, }, }); @@ -468,10 +467,7 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { body.append('text', message); await this._post(`changeset/${changesetId}/comment`, body, { - headers: { - 'Authorization': this._requestHeaders['Authorization'], - 'X-Workspace': workspaceId, - }, + headers: { 'X-Workspace': workspaceId }, }); } @@ -484,7 +480,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { const response = await this._get(`notes/search.json?${params}`, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId, }, @@ -497,7 +492,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { const bboxParam = await this.getExportBbox(workspaceId); const response = await this._get(`map.json?bbox=${bboxParam}`, { headers: { - ...this._requestHeaders, 'Accept': 'application/json', 'X-Workspace': workspaceId } @@ -510,7 +504,6 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { const bboxParam = await this.getExportBbox(workspaceId); const response = await this._get(`map?bbox=${bboxParam}`, { headers: { - ...this._requestHeaders, 'Accept': 'application/xml', 'X-Workspace': workspaceId } @@ -525,17 +518,23 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient { } } - async _send(url: string, method: string, body?: any, config?: object): Promise { + override async _send( + url: string, + method: string, + body?: HttpBody, + config?: FetchConfig, + ): Promise { try { await this.#tdeiClient.tryRefreshAuth(); this.#setAuthHeader(); - const requestOptions = { - credentials: 'include' - } + const requestOptions: FetchConfig = { + credentials: 'include', + }; return await super._send(url, method, body, { ...requestOptions, ...config }); - } catch (e: any) { + } + catch (e: unknown) { if (e instanceof BaseHttpClientError) { throw new OsmApiClientError(e.response); } diff --git a/services/tdei.ts b/services/tdei.ts index 7c3b1e5..4561089 100644 --- a/services/tdei.ts +++ b/services/tdei.ts @@ -1,6 +1,11 @@ import { BlobReader, BlobWriter, ZipReader } from '@zip.js/zip.js'; -import { BaseHttpClient, BaseHttpClientError } from '~/services/http'; +import { + BaseHttpClient, + BaseHttpClientError, + type FetchConfig, + type HttpBody, +} from '~/services/http'; import type { ICancelableClient } from '~/services/loading'; import type { TdeiFeedback, @@ -237,13 +242,17 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { } async downloadOswDataset(tdeiRecordId: string, format: string = 'osw'): Promise { - const response = await this._sendTest(`osw/${tdeiRecordId}?format=${format}`, 'GET'); + const response = await this._get(`osw/${tdeiRecordId}?format=${format}`, { + headers: { Accept: '*/*' }, + }); return (await response.blob()); } async downloadPathwaysDataset(tdeiDatasetId: string): Promise { - const response = await this._sendTest(`gtfs-pathways/${tdeiDatasetId}`, 'GET'); + const response = await this._get(`gtfs-pathways/${tdeiDatasetId}`, { + headers: { Accept: '*/*' }, + }); return (await response.blob()); } @@ -284,9 +293,7 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { resource += '?derived_from_dataset_id=' + tdeiRecordId; } - const response = await this._post(resource, body, { - headers: { 'Authorization': this._requestHeaders['Authorization'] } - }); + const response = await this._post(resource, body); return await response.text(); } @@ -308,9 +315,7 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { resource += '?derived_from_dataset_id=' + tdeiRecordId; } - const response = await this._post(resource, body, { - headers: { 'Authorization': this._requestHeaders['Authorization'] } - }); + const response = await this._post(resource, body); return await response.text(); } @@ -327,19 +332,14 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { const filename = sourceFormat === 'osw' ? 'osw.zip' : 'osm.xml'; body.append('file', new File([dataset], filename)); - const jobResponse = await this._sendTest('osw/convert', 'POST', body); + const jobResponse = await this._post('osw/convert', body); const jobId = (await jobResponse.text()); while (true) { console.info(`Waiting for dataset conversion job ${jobId}...`); await new Promise(resolve => setTimeout(resolve, 4000)); - const statusResponse = await this._get(`jobs?job_id=${jobId}&tdei_project_group_id=${projectGroupId}`, { - headers: { - 'Accept': 'application/text', - 'Authorization': this._requestHeaders['Authorization'] - } - }); + const statusResponse = await this._get(`jobs?job_id=${jobId}&tdei_project_group_id=${projectGroupId}`); const statusBody = (await statusResponse.json())[0]; const statusText = statusBody.status.toLowerCase(); @@ -352,7 +352,9 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { } } - const fileResponse = await this._sendTest(`job/download/${jobId}`, 'GET'); + const fileResponse = await this._get(`job/download/${jobId}`, { + headers: { 'Accept': '*/*' }, + }); return await fileResponse.blob(); } @@ -424,14 +426,20 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient { } } - override async _send(url: string, method: string, body?: any, config?: object): Promise { + override async _send( + url: string, + method: string, + body?: HttpBody, + config?: FetchConfig, + ): Promise { try { if (this.#auth.needsRefresh) { await this.refreshToken(); } return await super._send(url, method, body, config); - } catch (e: any) { + } + catch (e: unknown) { if (e instanceof BaseHttpClientError) { throw new TdeiClientError(e.response); } @@ -515,13 +523,19 @@ export class TdeiUserClient extends BaseHttpClient implements ICancelableClient } } - override async _send(url: string, method: string, body?: any, config?: object): Promise { + override async _send( + url: string, + method: string, + body?: HttpBody, + config?: FetchConfig, + ): Promise { try { await this.#tdeiClient.tryRefreshAuth(); this.#setAuthHeader(); return await super._send(url, method, body, config); - } catch (e: any) { + } + catch (e: unknown) { if (e instanceof BaseHttpClientError) { throw new TdeiUserClientError(e.response); } diff --git a/services/workspaces.ts b/services/workspaces.ts index d9e9709..e9c25e8 100644 --- a/services/workspaces.ts +++ b/services/workspaces.ts @@ -1,4 +1,9 @@ -import { BaseHttpClient, BaseHttpClientError } from "~/services/http"; +import { + BaseHttpClient, + BaseHttpClientError, + type FetchConfig, + type HttpBody, +} from '~/services/http'; import { buildPathwaysCsvArchive } from '~/services/pathways'; import { compareStringAsc } from '~/util/compare'; @@ -238,15 +243,16 @@ export class WorkspacesClient extends BaseHttpClient implements ICancelableClien override async _send( url: string, method: string, - body?: any, - config?: object + body?: HttpBody, + config?: FetchConfig, ): Promise { try { await this.#tdeiClient.tryRefreshAuth(); this.#setAuthHeader(); return await super._send(url, method, body, config); - } catch (e) { + } + catch (e: unknown) { if (e instanceof BaseHttpClientError) { throw new WorkspacesClientError(e.response); }