Skip to content

Commit

Permalink
clean up HttpClient, rename baseUrl to host to match CLI
Browse files Browse the repository at this point in the history
  • Loading branch information
david-crespo committed Jul 21, 2023
1 parent 8ffcc89 commit cc91214
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 142 deletions.
131 changes: 61 additions & 70 deletions client/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,6 @@

import { camelToSnake, processResponseBody, snakeify, isNotNull } from "./util";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type QueryParamsType = Record<string, any>;

export interface FullRequestParams extends Omit<RequestInit, "body"> {
path: string;
query?: QueryParamsType;
body?: unknown;
baseUrl?: string;
}

export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;

export interface ApiConfig {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "signal">;
}

/** Success responses from the API */
export type ApiSuccess<Data> = {
type: "success";
Expand Down Expand Up @@ -83,19 +63,6 @@ function encodeQueryParam(key: string, value: unknown) {
)}`;
}

/** Query params with null values filtered out. `"?"` included. */
export function toQueryString(rawQuery?: QueryParamsType): string {
const qs = Object.entries(rawQuery || {})
.filter(([_key, value]) => isNotNull(value))
.map(([key, value]) =>
Array.isArray(value)
? value.map((item) => encodeQueryParam(key, item)).join("&")
: encodeQueryParam(key, value)
)
.join("&");
return qs ? "?" + qs : "";
}

export async function handleResponse<Data>(
response: Response
): Promise<ApiResult<Data>> {
Expand Down Expand Up @@ -135,51 +102,75 @@ export async function handleResponse<Data>(
};
}

export class HttpClient {
public baseUrl = "";
// has to be any. the particular query params types don't like unknown
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type QueryParams = Record<string, any>;

private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
/** Params that get passed to `fetch`. A subset of `RequestInit`. */
export interface RequestParams extends Omit<RequestInit, "body" | "method"> {}

constructor(apiConfig: ApiConfig = {}) {
Object.assign(this, apiConfig);
}
/** All arguments to `request()` */
export interface FullParams extends RequestParams {
path: string;
query?: QueryParams;
body?: unknown;
host?: string;
method?: string;
}

private mergeRequestParams(params: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params,
headers: {
...this.baseApiParams.headers,
...params.headers,
},
};
export interface ApiConfig {
host?: string;
baseParams?: RequestParams;
}

export class HttpClient {
host: string;
baseParams: RequestParams;

constructor({ host, baseParams }: ApiConfig = {}) {
this.host = host || "";
this.baseParams = mergeParams(
{ headers: { "Content-Type": "application/json" } },
baseParams || {}
);
}

public request = async <Data>({
public async request<Data>({
body,
path,
query,
baseUrl,
...params
}: FullRequestParams): Promise<ApiResult<Data>> => {
const requestParams = this.mergeRequestParams(params);

const url = (baseUrl || this.baseUrl || "") + path + toQueryString(query);

const response = await fetch(url, {
...requestParams,
headers: {
"Content-Type": "application/json",
...requestParams.headers,
},
host,
...fetchParams
}: FullParams): Promise<ApiResult<Data>> {
const url = (host || this.host) + path + toQueryString(query);
const init = {
...mergeParams(this.baseParams, fetchParams),
body: JSON.stringify(snakeify(body), replacer),
});
};
return handleResponse(await fetch(url, init));
}
}

return handleResponse(response);
};
export function mergeParams(a: RequestParams, b: RequestParams): RequestParams {
// calling `new Headers()` normalizes `HeadersInit`, which could be a Headers
// object, a plain object, or an array of tuples
const headers = new Headers(a.headers);
for (const [key, value] of new Headers(b.headers).entries()) {
headers.append(key, value);
}

return { ...a, ...b, headers };
}

/** Query params with null values filtered out. `"?"` included. */
export function toQueryString(rawQuery?: QueryParams): string {
const qs = Object.entries(rawQuery || {})
.filter(([_key, value]) => isNotNull(value))
.map(([key, value]) =>
Array.isArray(value)
? value.map((item) => encodeQueryParam(key, item)).join("&")
: encodeQueryParam(key, value)
)
.join("&");
return qs ? "?" + qs : "";
}
24 changes: 23 additions & 1 deletion static/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* Copyright Oxide Computer Company
*/

import { handleResponse } from "./http-client";
import { handleResponse, mergeParams } from "./http-client";
import { describe, expect, it } from "vitest";

const headers = { "Content-Type": "application/json" };
Expand Down Expand Up @@ -73,3 +73,25 @@ describe("handleResponse", () => {
expect(result.headers.get("Content-Type")).toBe("application/json");
});
});

describe("mergeParams", () => {
it("handles empty objects", () => {
expect(mergeParams({}, {})).toEqual({ headers: new Headers() });
});

it("merges headers of different formats", () => {
const obj = { headers: { a: "b" } };
const headers = { headers: new Headers({ c: "d" }) };
const tuples = { headers: [["e", "f"]] as HeadersInit };

expect(mergeParams(obj, headers)).toEqual({
headers: new Headers({ a: "b", c: "d" }),
});
expect(mergeParams(obj, tuples)).toEqual({
headers: new Headers({ a: "b", e: "f" }),
});
expect(mergeParams(tuples, headers)).toEqual({
headers: new Headers({ e: "f", c: "d" }),
});
});
});
131 changes: 61 additions & 70 deletions static/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,6 @@

import { camelToSnake, processResponseBody, snakeify, isNotNull } from "./util";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type QueryParamsType = Record<string, any>;

export interface FullRequestParams extends Omit<RequestInit, "body"> {
path: string;
query?: QueryParamsType;
body?: unknown;
baseUrl?: string;
}

export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;

export interface ApiConfig {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "signal">;
}

/** Success responses from the API */
export type ApiSuccess<Data> = {
type: "success";
Expand Down Expand Up @@ -83,19 +63,6 @@ function encodeQueryParam(key: string, value: unknown) {
)}`;
}

/** Query params with null values filtered out. `"?"` included. */
export function toQueryString(rawQuery?: QueryParamsType): string {
const qs = Object.entries(rawQuery || {})
.filter(([_key, value]) => isNotNull(value))
.map(([key, value]) =>
Array.isArray(value)
? value.map((item) => encodeQueryParam(key, item)).join("&")
: encodeQueryParam(key, value)
)
.join("&");
return qs ? "?" + qs : "";
}

export async function handleResponse<Data>(
response: Response
): Promise<ApiResult<Data>> {
Expand Down Expand Up @@ -135,51 +102,75 @@ export async function handleResponse<Data>(
};
}

export class HttpClient {
public baseUrl = "";
// has to be any. the particular query params types don't like unknown
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type QueryParams = Record<string, any>;

private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
/** Params that get passed to `fetch`. A subset of `RequestInit`. */
export interface RequestParams extends Omit<RequestInit, "body" | "method"> {}

constructor(apiConfig: ApiConfig = {}) {
Object.assign(this, apiConfig);
}
/** All arguments to `request()` */
export interface FullParams extends RequestParams {
path: string;
query?: QueryParams;
body?: unknown;
host?: string;
method?: string;
}

private mergeRequestParams(params: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params,
headers: {
...this.baseApiParams.headers,
...params.headers,
},
};
export interface ApiConfig {
host?: string;
baseParams?: RequestParams;
}

export class HttpClient {
host: string;
baseParams: RequestParams;

constructor({ host, baseParams }: ApiConfig = {}) {
this.host = host || "";
this.baseParams = mergeParams(
{ headers: { "Content-Type": "application/json" } },
baseParams || {}
);
}

public request = async <Data>({
public async request<Data>({
body,
path,
query,
baseUrl,
...params
}: FullRequestParams): Promise<ApiResult<Data>> => {
const requestParams = this.mergeRequestParams(params);

const url = (baseUrl || this.baseUrl || "") + path + toQueryString(query);

const response = await fetch(url, {
...requestParams,
headers: {
"Content-Type": "application/json",
...requestParams.headers,
},
host,
...fetchParams
}: FullParams): Promise<ApiResult<Data>> {
const url = (host || this.host) + path + toQueryString(query);
const init = {
...mergeParams(this.baseParams, fetchParams),
body: JSON.stringify(snakeify(body), replacer),
});

return handleResponse(response);
};
return handleResponse(await fetch(url, init));
};
}

export function mergeParams(a: RequestParams, b: RequestParams): RequestParams {
// calling `new Headers()` normalizes `HeadersInit`, which could be a Headers
// object, a plain object, or an array of tuples
const headers = new Headers(a.headers);
for (const [key, value] of new Headers(b.headers).entries()) {
headers.append(key, value);
}

return { ...a, ...b, headers };
}

/** Query params with null values filtered out. `"?"` included. */
export function toQueryString(rawQuery?: QueryParams): string {
const qs = Object.entries(rawQuery || {})
.filter(([_key, value]) => isNotNull(value))
.map(([key, value]) =>
Array.isArray(value)
? value.map((item) => encodeQueryParam(key, item)).join("&")
: encodeQueryParam(key, value)
)
.join("&");
return qs ? "?" + qs : "";
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"lib": ["es2019", "dom"],
"lib": ["es2019", "dom", "DOM.Iterable"],
"module": "es2020",
"moduleResolution": "node",
"resolveJsonModule": true,
Expand Down

0 comments on commit cc91214

Please sign in to comment.