Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | 1x 1x 9x 3x 2x 1x 1x 1x 2x 1x 11x 1x 10x 9x 1x 1x 7x 7x 7x 7x 15x 15x 12x 3x 11x 7x 15x 7x 15x | import { Context, Effect, flow, Layer, Match, Option, Schema } from 'effect';
import {
FetchHttpClient,
HttpClient,
type HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from 'effect/http';
import { AppConfig } from '@/lib/config';
import { AuthToken } from './auth';
import {
type ApiError,
ForbiddenError,
InvalidResponseError,
NetworkError,
NotFoundError,
ServerError,
UnauthorizedError,
ValidationError,
} from './errors';
/** Body the API sends with a 400/422: a message and optional per-field messages. */
const ValidationBody = Schema.Struct({
message: Schema.optional(Schema.String),
errors: Schema.optional(Schema.Record(Schema.String, Schema.String)),
});
const fromStatus = (
response: HttpClientResponse.HttpClientResponse,
): Effect.Effect<never, ApiError> => {
switch (response.status) {
case 401:
return Effect.fail(new UnauthorizedError());
case 403:
return Effect.fail(new ForbiddenError());
case 404:
return Effect.fail(new NotFoundError());
case 400:
case 422:
return response.json.pipe(
Effect.flatMap(Schema.decodeUnknownEffect(ValidationBody)),
// A malformed error body still means "invalid input", just without field details.
Effect.catch(() => Effect.succeed({ message: undefined, errors: undefined })),
Effect.flatMap(({ message, errors }) =>
Effect.fail(
new ValidationError({
message: message ?? 'Please check the highlighted fields.',
fields: errors ?? {},
}),
),
),
);
default:
return Effect.fail(new ServerError({ status: response.status }));
}
};
/** Maps Effect's transport-level errors onto the app's `ApiError` union. */
const toApiError = (
error: HttpClientError.HttpClientError | Schema.SchemaError,
): Effect.Effect<never, ApiError> =>
Match.value(error).pipe(
Match.tag('SchemaError', ({ message }) => Effect.fail(new InvalidResponseError({ message }))),
Match.orElse(({ message, reason }) =>
Match.value(reason).pipe(
Match.tagsExhaustive({
StatusCodeError: ({ response }) => fromStatus(response),
DecodeError: () => Effect.fail(new InvalidResponseError({ message })),
EmptyBodyError: () => Effect.fail(new InvalidResponseError({ message })),
TransportError: () => Effect.fail(new NetworkError()),
EncodeError: () => Effect.fail(new NetworkError()),
InvalidUrlError: () => Effect.fail(new NetworkError()),
}),
),
),
);
/**
* The only way the app talks to the backend. Every method decodes the response with the
* schema you pass and fails with an `ApiError`, so callers never see raw HTTP errors.
*
* Retries are owned by TanStack Query (see query-client.ts); do not add
* `HttpClient.retryTransient` here or every retry multiplies.
*/
export class ApiClient extends Context.Service<
ApiClient,
{
readonly get: <S extends Schema.Constraint>(
path: string,
schema: S,
options?: { readonly urlParams?: Record<string, string> },
) => Effect.Effect<S['Type'], ApiError, S['DecodingServices']>;
readonly post: <S extends Schema.Constraint>(
path: string,
// The outbound boundary: the body is JSON-encoded as given (a feature passes its own Schema
// type) and nothing reads it here, so it stays unknown.
// oxlint-disable-next-line anti-slop/no-unknown-parameters
body: unknown,
schema: S,
) => Effect.Effect<S['Type'], ApiError, S['DecodingServices']>;
readonly put: <S extends Schema.Constraint>(
path: string,
// The outbound boundary: the body is JSON-encoded as given (a feature passes its own Schema
// type) and nothing reads it here, so it stays unknown.
// oxlint-disable-next-line anti-slop/no-unknown-parameters
body: unknown,
schema: S,
) => Effect.Effect<S['Type'], ApiError, S['DecodingServices']>;
readonly patch: <S extends Schema.Constraint>(
path: string,
// The outbound boundary: the body is JSON-encoded as given (a feature passes its own Schema
// type) and nothing reads it here, so it stays unknown.
// oxlint-disable-next-line anti-slop/no-unknown-parameters
body: unknown,
schema: S,
) => Effect.Effect<S['Type'], ApiError, S['DecodingServices']>;
readonly delete: (path: string) => Effect.Effect<void, ApiError>;
}
>()('app/lib/api/ApiClient') {
static readonly layer = Layer.effect(
ApiClient,
Effect.gen(function* () {
const { apiUrl } = yield* AppConfig;
const auth = yield* AuthToken;
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.mapRequest(
flow(HttpClientRequest.prependUrl(apiUrl), HttpClientRequest.acceptJson),
),
HttpClient.filterStatusOk,
);
// `Authorization: Bearer <token>` whenever AuthToken has one (see auth.ts). The token is read
// once per request, so a 401 names the token it rejected (`AuthToken.rejected`).
const execute = <A, R>(
request: HttpClientRequest.HttpClientRequest,
handle: (
response: HttpClientResponse.HttpClientResponse,
) => Effect.Effect<A, HttpClientError.HttpClientError | Schema.SchemaError, R>,
) =>
auth.get.pipe(
Effect.flatMap((token) =>
client
.execute(
Option.match(token, {
onNone: () => request,
onSome: (value) => HttpClientRequest.bearerToken(request, value),
}),
)
.pipe(
Effect.flatMap(handle),
Effect.catch(toApiError),
Effect.tapError((error) =>
error instanceof UnauthorizedError && Option.isSome(token)
? auth.rejected(token.value)
: Effect.void,
),
),
),
);
const send = <S extends Schema.Constraint>(
request: HttpClientRequest.HttpClientRequest,
schema: S,
) => execute(request, HttpClientResponse.schemaBodyJson(schema));
return ApiClient.of({
get: (path, schema, { urlParams = {} } = {}) =>
send(HttpClientRequest.get(path, { urlParams }), schema),
post: (path, body, schema) =>
send(HttpClientRequest.post(path).pipe(HttpClientRequest.bodyJsonUnsafe(body)), schema),
put: (path, body, schema) =>
send(HttpClientRequest.put(path).pipe(HttpClientRequest.bodyJsonUnsafe(body)), schema),
patch: (path, body, schema) =>
send(HttpClientRequest.patch(path).pipe(HttpClientRequest.bodyJsonUnsafe(body)), schema),
delete: (path) => execute(HttpClientRequest.delete(path), () => Effect.void),
});
}),
).pipe(Layer.provide(FetchHttpClient.layer));
}
|