1import {
2 EnvironmentHttpApi,
3 EnvironmentHttpCommonError,
4 type EnvironmentAuthInvalidError,
5 type EnvironmentInternalError,
6 type EnvironmentOperationForbiddenError,
7 type EnvironmentRequestInvalidError,
8 type EnvironmentResourceNotFoundError,
9 type EnvironmentScopeRequiredError,
10} from "@t3tools/contracts";
11import { normalizeHttpBaseUrl } from "@t3tools/shared/advertisedEndpoint";
12import * as HttpObservability from "@t3tools/shared/httpObservability";
13import * as Data from "effect/Data";
14import * as Duration from "effect/Duration";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Option from "effect/Option";
18import * as Schema from "effect/Schema";
19import { FetchHttpClient, HttpClient, HttpClientError } from "effect/http";
20import * as HttpApiClient from "effect/http-api/HttpApiClient";
21
22const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError);
23
24export class RemoteEnvironmentAuthFetchError extends Data.TaggedError(
25 "RemoteEnvironmentAuthFetchError",
26)<{
27 readonly message: string;
28 readonly cause: unknown;
29}> {}
30
31export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError(
32 "RemoteEnvironmentAuthInvalidJsonError",
33)<{
34 readonly message: string;
35 readonly cause: unknown;
36}> {}
37
38export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError(
39 "RemoteEnvironmentAuthUndeclaredStatusError",
40)<{
41 readonly message: string;
42 readonly status: number;
43 readonly requestUrl: string;
44}> {
45 constructor(requestUrl: string, status: number) {
46 super({
47 message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`,
48 requestUrl,
49 status,
50 });
51 }
52}
53
54export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError(
55 "RemoteEnvironmentAuthTimeoutError",
56)<{
57 readonly message: string;
58 readonly requestUrl: string;
59 readonly timeoutMs: number;
60}> {
61 constructor(requestUrl: string, timeoutMs: number) {
62 super({
63 message: `Remote environment endpoint ${requestUrl} timed out after ${timeoutMs}ms.`,
64 requestUrl,
65 timeoutMs,
66 });
67 }
68}
69
70export type RemoteEnvironmentRequestError =
71 | EnvironmentRequestInvalidError
72 | EnvironmentAuthInvalidError
73 | EnvironmentScopeRequiredError
74 | EnvironmentOperationForbiddenError
75 | EnvironmentResourceNotFoundError
76 | EnvironmentInternalError
77 | RemoteEnvironmentAuthFetchError
78 | RemoteEnvironmentAuthInvalidJsonError
79 | RemoteEnvironmentAuthUndeclaredStatusError
80 | RemoteEnvironmentAuthTimeoutError;
81
82export const layerRemoteHttpClient = (
83 fetchFn: typeof globalThis.fetch,
84): Layer.Layer<HttpClient.HttpClient> =>
85 Layer.merge(
86 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))),
87 HttpObservability.layer,
88 );
89
90export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) =>
91 HttpApiClient.make(EnvironmentHttpApi, {
92 baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
93 });
94
95export const makeEnvironmentHttpApiGroupClient = <
96 Group extends keyof typeof EnvironmentHttpApi.groups,
97>(
98 httpBaseUrl: string,
99 group: Group,
100) =>
101 Effect.flatMap(HttpClient.HttpClient, (httpClient) =>
102 HttpApiClient.group(EnvironmentHttpApi, {
103 httpClient,
104 group,
105 baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
106 }),
107 );
108
109/** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */
110export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) =>
111 HttpApiClient.urlBuilder(EnvironmentHttpApi, {
112 baseUrl: normalizeHttpBaseUrl(httpBaseUrl),
113 });
114
115const failRemoteRequest = (
116 requestUrl: string,
117 cause: unknown,
118): Effect.Effect<never, RemoteEnvironmentRequestError> => {
119 if (cause instanceof RemoteEnvironmentAuthTimeoutError) {
120 return Effect.fail(cause);
121 }
122 if (isEnvironmentHttpCommonError(cause)) {
123 return Effect.fail(cause);
124 }
125 if (Schema.isSchemaError(cause)) {
126 return Effect.fail(
127 new RemoteEnvironmentAuthInvalidJsonError({
128 message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`,
129 cause,
130 }),
131 );
132 }
133 if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) {
134 const response = cause.response;
135 if (response.status < 200 || response.status >= 300) {
136 return Effect.fail(
137 new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status),
138 );
139 }
140 return Effect.fail(
141 new RemoteEnvironmentAuthInvalidJsonError({
142 message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`,
143 cause,
144 }),
145 );
146 }
147 return Effect.fail(
148 new RemoteEnvironmentAuthFetchError({
149 message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`,
150 cause,
151 }),
152 );
153};
154
155export const executeEnvironmentHttpRequest = <A, E, R>(
156 requestUrl: string,
157 timeoutMs: number,
158 request: Effect.Effect<A, E, R>,
159): Effect.Effect<A, RemoteEnvironmentRequestError, R> =>
160 request.pipe(
161 Effect.timeoutOption(Duration.millis(timeoutMs)),
162 Effect.flatMap(
163 Option.match({
164 onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)),
165 onSome: Effect.succeed,
166 }),
167 ),
168 Effect.catch((cause) => failRemoteRequest(requestUrl, cause)),
169 );