1import * as ag from "@distilled.cloud/aws/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import type { Input } from "../../Input.ts";
6import * as Provider from "../../Provider.ts";
7import {
8 Resource as ResourceFactory,
9 type Resource as ResourceType,
10} from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12import type { RestApi } from "./RestApi.ts";
13
14export interface ApiGatewayResourceProps {
15 /**
16 * The `RestApi` this path segment belongs to. Binding via `restApi`
17 * registers this resource on the API so that any `Deployment` of the
18 * same API waits for it — and for the methods attached to it — before
19 * snapshotting.
20 */
21 restApi?: RestApi;
22 /**
23 * Identifier of the parent REST API. Usually derived from `restApi.restApiId`.
24 */
25 restApiId?: Input<string>;
26 /**
27 * Parent resource id (use `api.rootResourceId` for top-level paths, or
28 * the `resourceId` of another `ApiGateway.Resource` to nest deeper).
29 */
30 parentId: Input<string>;
31 /**
32 * Path segment (e.g. `items` or `{proxy+}`).
33 */
34 pathPart: string;
35}
36
37export interface ApiGatewayResource extends ResourceType<
38 "AWS.ApiGateway.Resource",
39 ApiGatewayResourceProps,
40 {
41 resourceId: string;
42 restApiId: string;
43 parentId: string;
44 pathPart: string;
45 },
46 never,
47 Providers
48> {}
49
50/**
51 * A path segment under a REST API resource tree.
52 *
53 * Resources form the URL hierarchy of a REST API: every path segment
54 * (`/items`, `/items/{id}`, `/{proxy+}`) is a `Resource` whose `parentId`
55 * points either at `api.rootResourceId` (for top-level paths) or at
56 * another `Resource`'s `resourceId` (for nested paths). Attach methods
57 * to a resource by passing its `resourceId` to `ApiGateway.Method`.
58 * ### Path resources
59 * **Example:** Top-level path
60 * ```typescript
61 * const items = yield* ApiGateway.Resource("Items", {
62 * restApi: api,
63 * parentId: api.rootResourceId,
64 * pathPart: "items",
65 * });
66 * ```
67 *
68 * **Example:** Nested path with a greedy proxy
69 * ```typescript
70 * const items = yield* ApiGateway.Resource("Items", {
71 * restApi: api,
72 * parentId: api.rootResourceId,
73 * pathPart: "items",
74 * });
75 *
76 * const anyItem = yield* ApiGateway.Resource("AnyItem", {
77 * restApi: api,
78 * parentId: items.resourceId,
79 * pathPart: "{proxy+}",
80 * });
81 * ```
82 *
83 * @resource
84 */
85export const GatewayResource = ResourceFactory<ApiGatewayResource>(
86 "AWS.ApiGateway.Resource",
87);
88
89interface ApiGatewayResourceInputProps {
90 restApi?: RestApi;
91 restApiId?: Input<string>;
92 parentId: Input<string>;
93 pathPart: Input<string>;
94}
95
96/**
97 * User-facing wrapper. Accepts `restApi: RestApi` to register this resource
98 * as a binding on the API (so deployments wait for it transitively).
99 */
100const ResourceImpl = (id: string, props: ApiGatewayResourceInputProps) =>
101 Effect.gen(function* () {
102 const { restApi, ...rest } = props;
103 const restApiId = rest.restApiId ?? restApi?.restApiId;
104 if (!restApiId) {
105 return yield* Effect.die(
106 "ApiGateway.Resource requires either `restApi` (preferred) or " +
107 "explicit `restApiId`.",
108 );
109 }
110 const resource = yield* GatewayResource(id, {
111 ...rest,
112 restApiId,
113 } as any);
114 if (restApi) {
115 yield* restApi.bind`${resource}`({
116 kind: "resource",
117 resourceId: resource.resourceId,
118 parentId: resource.parentId,
119 pathPart: resource.pathPart,
120 });
121 }
122 return resource;
123 });
124
125export const Resource = ResourceImpl;
126
127export const ResourceProvider = () =>
128 Provider.effect(
129 GatewayResource,
130 Effect.gen(function* () {
131 return {
132 stables: ["resourceId", "restApiId", "parentId"] as const,
133 diff: Effect.fn(function* ({ news: newsIn, olds }) {
134 if (!isResolved(newsIn)) return;
135 const news = newsIn as Input.ResolveProps<ApiGatewayResourceProps>;
136 if (
137 news.restApiId !== olds.restApiId ||
138 news.pathPart !== olds.pathPart ||
139 news.parentId !== olds.parentId
140 ) {
141 return { action: "replace" } as const;
142 }
143 }),
144 list: () =>
145 Effect.gen(function* () {
146 // Enumerate parent RestApis, then list the resources under each.
147 const apiIds = yield* ag.getRestApis.pages({}).pipe(
148 Stream.runCollect,
149 Effect.map((chunk) =>
150 Array.from(chunk).flatMap((page) =>
151 (page.items ?? [])
152 .filter((api) => api.id != null)
153 .map((api) => api.id!),
154 ),
155 ),
156 );
157 const rows = yield* Effect.forEach(
158 apiIds,
159 (restApiId) =>
160 ag.getResources.pages({ restApiId }).pipe(
161 Stream.runCollect,
162 Effect.map((chunk) =>
163 Array.from(chunk).flatMap((page) =>
164 (page.items ?? [])
165 // The API root resource ("/") has no parentId/pathPart;
166 // it isn't a managed `Resource`, so skip it.
167 .filter(
168 (r) =>
169 r.id != null &&
170 r.parentId != null &&
171 r.pathPart != null,
172 )
173 .map((r) => ({
174 resourceId: r.id!,
175 restApiId,
176 parentId: r.parentId!,
177 pathPart: r.pathPart!,
178 })),
179 ),
180 ),
181 // A RestApi can vanish mid-enumeration; skip it.
182 Effect.catchTag("NotFoundException", () =>
183 Effect.succeed([]),
184 ),
185 ),
186 { concurrency: 10 },
187 );
188 return rows.flat();
189 }),
190 read: Effect.fn(function* ({ output }) {
191 if (!output?.resourceId) return undefined;
192 const r = yield* ag
193 .getResource({
194 restApiId: output.restApiId,
195 resourceId: output.resourceId,
196 })
197 .pipe(
198 Effect.catchTag("NotFoundException", () =>
199 Effect.succeed(undefined),
200 ),
201 );
202 if (!r?.id) return undefined;
203 return {
204 resourceId: r.id,
205 restApiId: output.restApiId,
206 parentId: r.parentId!,
207 pathPart: r.pathPart!,
208 };
209 }),
210 reconcile: Effect.fn(function* ({ news: newsIn, output, session }) {
211 if (!isResolved(newsIn)) {
212 return yield* Effect.die("Resource props were not resolved");
213 }
214 const news = newsIn as Input.ResolveProps<ApiGatewayResourceProps>;
215 const restApiId = (output?.restApiId ?? news.restApiId) as string;
216
217 // Observe — fetch the live resource if we have a cached id. We
218 // never trust `output.pathPart` for diffing; we re-read the
219 // pathPart from cloud state every reconcile.
220 let observed = output?.resourceId
221 ? yield* ag
222 .getResource({
223 restApiId,
224 resourceId: output.resourceId,
225 })
226 .pipe(
227 Effect.catchTag("NotFoundException", () =>
228 Effect.succeed(undefined),
229 ),
230 )
231 : undefined;
232
233 // Ensure — create the resource if missing.
234 if (!observed?.id) {
235 const created = yield* ag.createResource({
236 restApiId: news.restApiId as string,
237 parentId: news.parentId as string,
238 pathPart: news.pathPart,
239 });
240 if (!created.id) {
241 return yield* Effect.die("createResource missing id");
242 }
243 yield* session.note(
244 `Created API Gateway resource ${created.id} (${news.pathPart})`,
245 );
246 observed = yield* ag.getResource({
247 restApiId: news.restApiId as string,
248 resourceId: created.id,
249 });
250 }
251
252 const resourceId = observed.id!;
253
254 // Sync pathPart — observed ↔ desired. parentId moves are modeled
255 // as `replace` in `diff`, so we don't try to patch them here.
256 if (news.pathPart !== observed.pathPart) {
257 yield* ag.updateResource({
258 restApiId,
259 resourceId,
260 patchOperations: [
261 { op: "replace", path: "/pathPart", value: news.pathPart },
262 ],
263 });
264 yield* session.note(`Updated resource ${resourceId}`);
265 }
266
267 return {
268 resourceId,
269 restApiId,
270 parentId: observed.parentId!,
271 pathPart: news.pathPart,
272 };
273 }),
274 delete: Effect.fn(function* ({ output, session }) {
275 yield* ag
276 .deleteResource({
277 restApiId: output.restApiId,
278 resourceId: output.resourceId,
279 })
280 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
281 yield* session.note(`Deleted resource ${output.resourceId}`);
282 }),
283 };
284 }),
285 );