Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/effect/src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,3 +609,21 @@ export type ReasonTags<E> = E extends { readonly reason: { readonly _tag: string
export type ExtractReason<E, K extends string> = E extends { readonly reason: infer R }
? Extract<R, { readonly _tag: K }>
: never

/**
* Extracts only the required properties from an object type.
*
* @since 4.0.0
* @category types
*/
export type RequiredKeys<T> = keyof T extends infer K ? K extends keyof T ? {} extends Pick<T, K> ? never : K : never
: never

/**
* Checks if a type has no required keys and returns `Yes` if true, otherwise
* `No`.
*
* @since 4.0.0
* @category types
*/
export type NoRequiredKeysWith<T, Yes, No> = RequiredKeys<T> extends never ? Yes : No
184 changes: 184 additions & 0 deletions packages/effect/src/unstable/httpapi/HttpApiClientFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* @since 4.0.0
*/
import type { NoRequiredKeysWith, Simplify } from "../../Types.ts"
import type * as HttpApi from "./HttpApi.ts"
import type * as HttpApiEndpoint from "./HttpApiEndpoint.ts"
import type * as HttpApiGroup from "./HttpApiGroup.ts"
import type * as HttpApiSchema from "./HttpApiSchema.ts"

/**
* Creates an HTTP API client using the Fetch API. It has no dependency on
* effect modules so can be used in bundle size concerned environments.
*
* @since 4.0.0
* @category Constructors
*/
export const make = <
Api extends HttpApi.Any
>(options?: {
readonly baseUrl?: string | undefined
readonly fetch?: typeof fetch | undefined
readonly defaultHeaders?: HeadersInit | undefined
}): HttpApiClientFetch<
Api extends HttpApi.HttpApi<infer _Id, infer Groups> ? EndpointMap<Groups> : {}
> => {
const fetchImpl = options?.fetch ?? fetch
let baseUrl = options?.baseUrl ?? ""
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1)
}

return async function(methodAndUrl: string, opts?: {
readonly signal?: AbortSignal | undefined
readonly path?: Record<string, any> | undefined
readonly urlParams?: Record<string, any> | undefined
readonly headers?: Record<string, string> | undefined
readonly payload?: any
readonly payloadType?: "json" | "urlEncoded" | undefined
readonly formData?: Record<string, any> | undefined
}) {
const headers = new Headers(options?.defaultHeaders)
if (opts?.headers) {
for (const [key, value] of Object.entries(opts.headers)) {
headers.set(key, value)
}
}

const [method, urlTemplate] = methodAndUrl.split(" ")
let path = urlTemplate
if (opts?.path) {
for (const [key, value] of Object.entries(opts.path)) {
path = path.replace(`:${key}`, encodeURIComponent(String(value)))
}
}

const url = new URL(baseUrl + path, "location" in globalThis ? globalThis.location?.origin : undefined)
if (opts?.urlParams) {
for (const [key, value] of Object.entries(opts.urlParams)) {
url.searchParams.set(key, String(value))
}
}

const fetchOptions: RequestInit = {
method,
headers,
signal: opts?.signal ?? null
}
if (opts?.payload !== undefined && opts?.payloadType !== "urlEncoded") {
headers.set("Content-Type", "application/json")
fetchOptions.body = JSON.stringify(opts.payload)
} else if (opts?.formData !== undefined) {
const formData = new FormData()
for (const [key, value] of Object.entries(opts.formData)) {
formData.append(key, value)
}
fetchOptions.body = formData
} else if (opts?.payload !== undefined) {
headers.set("Content-Type", "application/x-www-form-urlencoded")
const urlSearchParams = new URLSearchParams()
for (const [key, value] of Object.entries(opts.payload)) {
urlSearchParams.append(key, String(value))
}
fetchOptions.body = urlSearchParams.toString()
}

const response = await fetchImpl(url.toString(), fetchOptions)
if (!response.ok) {
throw new Error(`HTTP error status: ${response.status}`)
}
return response
} as any
}

/**
* @since 4.0.0
* @category Models
*/
export type HttpApiClientFetch<
Endpoints extends Record<string, {
readonly options: any
readonly return: any
}>
> = <
const MethodAndUrl extends keyof Endpoints
>(
methodAndUrl: MethodAndUrl,
...args: NoRequiredKeysWith<
Endpoints[MethodAndUrl]["options"],
[options?: Endpoints[MethodAndUrl]["options"]],
[options: Endpoints[MethodAndUrl]["options"]]
>
) => Endpoints[MethodAndUrl]["return"]

/**
* @since 4.0.0
* @category Models
*/
export interface ResponseWith<A> extends Response {
readonly json: () => Promise<A>
}

/**
* @since 4.0.0
* @category Models
*/
export type EndpointMap<Group extends HttpApiGroup.Any> = {
readonly [Endpoint in HttpApiGroup.Endpoints<Group> as `${Endpoint["method"]} ${Endpoint["path"]}`]: {
readonly options: EndpointOptions<Endpoint>
readonly return: Promise<
ResponseWith<
Endpoint["successSchema"] extends undefined ? void : Endpoint["successSchema"]["Encoded"]
>
>
}
}

/**
* @since 4.0.0
* @category Models
*/
export type EndpointOptions<Endpoint extends HttpApiEndpoint.Any> = Endpoint extends HttpApiEndpoint.HttpApiEndpoint<
infer _Name,
infer _Method,
infer _Path,
infer _PathSchema,
infer _UrlParams,
infer _Payload,
infer _Headers,
infer _Success,
infer _Error,
infer _M,
infer _MR
> ? Simplify<
& { readonly signal?: AbortSignal | undefined }
& (Endpoint["pathSchema"] extends undefined ? {} :
NoRequiredKeysWith<_PathSchema["Encoded"], {
readonly path?: _PathSchema["Encoded"] | undefined
}, {
readonly path: _PathSchema["Encoded"]
}>)
& (Endpoint["urlParamsSchema"] extends undefined ? {} :
NoRequiredKeysWith<_UrlParams["Encoded"], {
readonly urlParams?: _UrlParams["Encoded"] | undefined
}, {
readonly urlParams: _UrlParams["Encoded"]
}>)
& (Endpoint["headersSchema"] extends undefined ? {} :
NoRequiredKeysWith<_Headers["Encoded"], {
readonly headers?: _Headers["Encoded"] | undefined
}, {
readonly headers: _Headers["Encoded"]
}>)
& (
Endpoint["payloadSchema"] extends undefined ? {} : _Payload extends HttpApiSchema.Multipart<infer S> ? {
// TODO: convert to string | Blob | File etc.
readonly formData: S["Encoded"]
} :
{
readonly payload: _Payload["Encoded"]
readonly payloadType?: "json" | "urlEncoded" | undefined
}
)
> :
{}
6 changes: 6 additions & 0 deletions packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,15 @@ export interface HttpApiEndpoint<
*/
export interface Any extends Pipeable {
readonly [TypeId]: any
readonly method: HttpMethod
readonly name: string
readonly path: string
readonly successSchema: Schema.Top
readonly errorSchema: Schema.Top
readonly pathSchema: Schema.Top | undefined
readonly urlParamsSchema: Schema.Top | undefined
readonly payloadSchema: Schema.Top | undefined
readonly headersSchema: Schema.Top | undefined
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/effect/src/unstable/httpapi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export * as HttpApiBuilder from "./HttpApiBuilder.ts"
*/
export * as HttpApiClient from "./HttpApiClient.ts"

/**
* @since 4.0.0
*/
export * as HttpApiClientFetch from "./HttpApiClientFetch.ts"

/**
* @since 4.0.0
*/
Expand Down
Loading
Loading