rastack 0.0.58 → 0.0.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +190 -0
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +1 -2
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +16 -1
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +24 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +1 -2
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +17 -1
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise an upstream response into rastack's REST contract — the DRF-shaped
|
|
3
|
+
* list envelope and bare detail object the typed hooks and `<DataTable>` expect,
|
|
4
|
+
* identical to what the Rust API and the WASM/local engine emit.
|
|
5
|
+
*
|
|
6
|
+
* External APIs rarely page the way rastack does (GitHub uses `per_page` + Link
|
|
7
|
+
* headers; a GraphQL list just returns an array), so the envelope is synthesised
|
|
8
|
+
* from what we have: the page the client asked for and the number of items this
|
|
9
|
+
* page actually returned. `count`/`last` are best-effort when the upstream total
|
|
10
|
+
* is unknown — enough for the pager to advance while there is a full page.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ensureId, projectFields } from "./util";
|
|
14
|
+
|
|
15
|
+
export interface ListEnvelope {
|
|
16
|
+
results: Record<string, unknown>[];
|
|
17
|
+
pagination: {
|
|
18
|
+
current: number;
|
|
19
|
+
last: number;
|
|
20
|
+
next: number | null;
|
|
21
|
+
previous: number | null;
|
|
22
|
+
page_size: number;
|
|
23
|
+
/** Best-effort total; equals what we can prove when the upstream omits it. */
|
|
24
|
+
count: number;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NormalizeOptions {
|
|
29
|
+
page: number;
|
|
30
|
+
pageSize: number;
|
|
31
|
+
primaryKey: string;
|
|
32
|
+
fields?: string[];
|
|
33
|
+
/** A known upstream total, if the transport could determine one. */
|
|
34
|
+
totalCount?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Shape a page of upstream items into the list envelope. */
|
|
38
|
+
export function toListEnvelope(
|
|
39
|
+
items: unknown,
|
|
40
|
+
opts: NormalizeOptions,
|
|
41
|
+
): ListEnvelope {
|
|
42
|
+
const rows = (Array.isArray(items) ? items : [])
|
|
43
|
+
.filter((r): r is Record<string, unknown> => !!r && typeof r === "object")
|
|
44
|
+
.map((r) => projectFields(ensureId(r, opts.primaryKey), opts.fields));
|
|
45
|
+
|
|
46
|
+
const { page, pageSize } = opts;
|
|
47
|
+
// A full page implies there may be more; a short page is the last one.
|
|
48
|
+
const hasMore = rows.length >= pageSize;
|
|
49
|
+
const count =
|
|
50
|
+
opts.totalCount ?? (page - 1) * pageSize + rows.length;
|
|
51
|
+
const last = opts.totalCount
|
|
52
|
+
? Math.max(1, Math.ceil(opts.totalCount / pageSize))
|
|
53
|
+
: hasMore
|
|
54
|
+
? page + 1
|
|
55
|
+
: page;
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
results: rows,
|
|
59
|
+
pagination: {
|
|
60
|
+
current: page,
|
|
61
|
+
last,
|
|
62
|
+
next: page < last ? page + 1 : null,
|
|
63
|
+
previous: page > 1 ? page - 1 : null,
|
|
64
|
+
page_size: pageSize,
|
|
65
|
+
count,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Shape a single upstream object into a rastack detail row. */
|
|
71
|
+
export function toDetail(
|
|
72
|
+
item: unknown,
|
|
73
|
+
primaryKey: string,
|
|
74
|
+
fields?: string[],
|
|
75
|
+
): Record<string, unknown> | null {
|
|
76
|
+
if (!item || typeof item !== "object") return null;
|
|
77
|
+
return projectFields(ensureId(item as Record<string, unknown>, primaryKey), fields);
|
|
78
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the upstream request for a GraphQL-backed resource (Expo's API is
|
|
3
|
+
* GraphQL, for instance), and pull the item array / detail object out of the
|
|
4
|
+
* `data` payload. A GraphQL call is always a POST of `{ query, variables }`; the
|
|
5
|
+
* rastack list params map onto the query's variables (`page_size` → `first`,
|
|
6
|
+
* etc.), so the same `/api/{app}/v1/{model}/` contract fronts a GraphQL API with
|
|
7
|
+
* no client awareness that it isn't REST.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { GraphQLBinding } from "./types";
|
|
11
|
+
import { firstParam, getPath } from "./util";
|
|
12
|
+
|
|
13
|
+
export interface GraphQLRequest {
|
|
14
|
+
url: string;
|
|
15
|
+
method: "POST";
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
body: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildVariables(
|
|
21
|
+
binding: GraphQLBinding,
|
|
22
|
+
query: Record<string, string[]>,
|
|
23
|
+
id?: string,
|
|
24
|
+
): Record<string, unknown> {
|
|
25
|
+
const vars: Record<string, unknown> = { ...(binding.variables ?? {}) };
|
|
26
|
+
const map = binding.variableMap ?? {};
|
|
27
|
+
const put = (rastackKey: keyof NonNullable<GraphQLBinding["variableMap"]>, raw?: string) => {
|
|
28
|
+
const name = map[rastackKey];
|
|
29
|
+
if (!name || raw == null || raw === "") return;
|
|
30
|
+
// page_size / page are numbers in GraphQL land; coerce when they parse.
|
|
31
|
+
const num = Number(raw);
|
|
32
|
+
vars[name] = Number.isFinite(num) && /^\d+$/.test(raw) ? num : raw;
|
|
33
|
+
};
|
|
34
|
+
put("page", firstParam(query, "page"));
|
|
35
|
+
put("page_size", firstParam(query, "page_size"));
|
|
36
|
+
put("search", firstParam(query, "search"));
|
|
37
|
+
put("order_by", firstParam(query, "order_by"));
|
|
38
|
+
if (id != null && map.id) vars[map.id] = id;
|
|
39
|
+
return vars;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the GraphQL list request. */
|
|
43
|
+
export function graphqlListRequest(
|
|
44
|
+
binding: GraphQLBinding,
|
|
45
|
+
query: Record<string, string[]>,
|
|
46
|
+
headers: Record<string, string>,
|
|
47
|
+
): GraphQLRequest {
|
|
48
|
+
return {
|
|
49
|
+
url: binding.baseUrl,
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
query: binding.listQuery,
|
|
54
|
+
variables: buildVariables(binding, query),
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Build the GraphQL detail request. Throws if the binding has no detail query. */
|
|
60
|
+
export function graphqlDetailRequest(
|
|
61
|
+
binding: GraphQLBinding,
|
|
62
|
+
id: string,
|
|
63
|
+
headers: Record<string, string>,
|
|
64
|
+
): GraphQLRequest {
|
|
65
|
+
if (!binding.detailQuery) {
|
|
66
|
+
throw new Error(`GraphQL binding ${binding.app}.${binding.model} has no detailQuery.`);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
url: binding.baseUrl,
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
query: binding.detailQuery,
|
|
74
|
+
variables: buildVariables(binding, {}, id),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A GraphQL response is `{ data, errors }`; surface errors, else return `data`. */
|
|
80
|
+
export function graphqlData(body: unknown): unknown {
|
|
81
|
+
const payload = body as { data?: unknown; errors?: { message?: string }[] } | null;
|
|
82
|
+
if (payload?.errors && payload.errors.length) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`GraphQL error: ${payload.errors.map((e) => e.message ?? "unknown").join("; ")}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return payload?.data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Extract the item array from a GraphQL list response. */
|
|
91
|
+
export function graphqlListItems(binding: GraphQLBinding, body: unknown): unknown {
|
|
92
|
+
return getPath(graphqlData(body), binding.itemsPath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Extract the detail object from a GraphQL detail response. */
|
|
96
|
+
export function graphqlDetailItem(binding: GraphQLBinding, body: unknown): unknown {
|
|
97
|
+
return getPath(graphqlData(body), binding.detailPath ?? binding.itemsPath);
|
|
98
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy core — one pure(ish) function from a rastack request to a rastack
|
|
3
|
+
* response, given the bindings and injected effects (`fetch`, cache, clock,
|
|
4
|
+
* auth). This is the "work" the user's Lambda does; the mini service inside
|
|
5
|
+
* `rastack dev` and the deployed Lambda are both thin adapters that parse an
|
|
6
|
+
* HTTP request into {@link RastackRequest}, call {@link handleExternalRequest},
|
|
7
|
+
* and serialise the result.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities, in order:
|
|
10
|
+
* 1. Resolve the binding for `app.model` (404 if none — not an external model).
|
|
11
|
+
* 2. Serve a fresh cache entry without any upstream call (the cache is the
|
|
12
|
+
* whole point of the proxy).
|
|
13
|
+
* 3. Otherwise call the upstream (REST or GraphQL), using a conditional
|
|
14
|
+
* `If-None-Match` when we hold an ETag so a `304` costs no rate-limit.
|
|
15
|
+
* 4. Normalise into the DRF envelope / detail row, cache it, and return it.
|
|
16
|
+
*
|
|
17
|
+
* Auth (the GitHub/Expo token from Secrets Manager in prod, an env var in dev)
|
|
18
|
+
* is injected by `deps.authHeaders`, so **no credential is ever hard-coded here**.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { cacheKey, isFresh, type ProxyCache } from "./cache";
|
|
22
|
+
import { toDetail, toListEnvelope } from "./envelope";
|
|
23
|
+
import {
|
|
24
|
+
graphqlDetailItem,
|
|
25
|
+
graphqlDetailRequest,
|
|
26
|
+
graphqlListItems,
|
|
27
|
+
graphqlListRequest,
|
|
28
|
+
} from "./graphql";
|
|
29
|
+
import {
|
|
30
|
+
restDetailRequest,
|
|
31
|
+
restListItems,
|
|
32
|
+
restListRequest,
|
|
33
|
+
type UpstreamRequest,
|
|
34
|
+
} from "./rest";
|
|
35
|
+
import {
|
|
36
|
+
findBinding,
|
|
37
|
+
type FetchLike,
|
|
38
|
+
type ResourceBinding,
|
|
39
|
+
type ResourceResolver,
|
|
40
|
+
} from "./types";
|
|
41
|
+
import { firstParam } from "./util";
|
|
42
|
+
|
|
43
|
+
export interface RastackRequest {
|
|
44
|
+
app: string;
|
|
45
|
+
model: string;
|
|
46
|
+
/** Present for a detail request (`/…/{model}/{id}/`). */
|
|
47
|
+
id?: string;
|
|
48
|
+
query: Record<string, string[]>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RastackResponse {
|
|
52
|
+
status: number;
|
|
53
|
+
body: unknown;
|
|
54
|
+
/** Whether this response was served from cache without an upstream call. */
|
|
55
|
+
cached: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HandlerDeps {
|
|
59
|
+
fetch: FetchLike;
|
|
60
|
+
cache: ProxyCache;
|
|
61
|
+
now: () => number;
|
|
62
|
+
/**
|
|
63
|
+
* Resolve upstream auth/headers for an app — e.g. `{ Authorization: "Bearer …" }`.
|
|
64
|
+
* Reads Secrets Manager in the Lambda; reads env vars in `rastack dev`.
|
|
65
|
+
*/
|
|
66
|
+
authHeaders?: (app: string) => Record<string, string> | Promise<Record<string, string>>;
|
|
67
|
+
/**
|
|
68
|
+
* Named resolvers for {@link ResolverBinding}s — the app plugs in transports
|
|
69
|
+
* the core doesn't ship (e.g. an AWS SDK resolver), keeping the core
|
|
70
|
+
* dependency-free.
|
|
71
|
+
*/
|
|
72
|
+
resolvers?: Record<string, ResourceResolver>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
76
|
+
|
|
77
|
+
function pageParams(query: Record<string, string[]>): { page: number; pageSize: number } {
|
|
78
|
+
const page = Math.max(1, parseInt(firstParam(query, "page") ?? "1", 10) || 1);
|
|
79
|
+
const pageSize = Math.max(
|
|
80
|
+
1,
|
|
81
|
+
parseInt(firstParam(query, "page_size") ?? "20", 10) || 20,
|
|
82
|
+
);
|
|
83
|
+
return { page, pageSize };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function upstreamHeaders(
|
|
87
|
+
binding: ResourceBinding,
|
|
88
|
+
deps: HandlerDeps,
|
|
89
|
+
): Promise<Record<string, string>> {
|
|
90
|
+
const auth = deps.authHeaders ? await deps.authHeaders(binding.app) : {};
|
|
91
|
+
return { ...(binding.headers ?? {}), ...auth };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fetch + parse an upstream request, applying a conditional ETag when supplied. */
|
|
95
|
+
async function callUpstream(
|
|
96
|
+
req: UpstreamRequest | { url: string; method: "POST"; headers: Record<string, string>; body: string },
|
|
97
|
+
deps: HandlerDeps,
|
|
98
|
+
etag?: string,
|
|
99
|
+
): Promise<{ status: number; body: unknown; etag?: string }> {
|
|
100
|
+
const headers = { ...req.headers };
|
|
101
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
102
|
+
const res = await deps.fetch(req.url, {
|
|
103
|
+
method: req.method,
|
|
104
|
+
headers,
|
|
105
|
+
body: "body" in req ? req.body : undefined,
|
|
106
|
+
});
|
|
107
|
+
if (res.status === 304) return { status: 304, body: undefined, etag };
|
|
108
|
+
const nextEtag = res.headers.get("etag") ?? undefined;
|
|
109
|
+
const body = res.status === 204 ? null : await res.json();
|
|
110
|
+
return { status: res.status, body, etag: nextEtag };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Handle one rastack request against the external bindings. */
|
|
114
|
+
export async function handleExternalRequest(
|
|
115
|
+
req: RastackRequest,
|
|
116
|
+
bindings: ResourceBinding[],
|
|
117
|
+
deps: HandlerDeps,
|
|
118
|
+
): Promise<RastackResponse> {
|
|
119
|
+
const binding = findBinding(bindings, req.app, req.model);
|
|
120
|
+
if (!binding) {
|
|
121
|
+
return { status: 404, body: { detail: `No external binding for ${req.app}.${req.model}.` }, cached: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ttl = binding.ttlMs ?? DEFAULT_TTL_MS;
|
|
125
|
+
const pk = binding.primaryKey ?? "id";
|
|
126
|
+
const key = cacheKey(req.app, req.model, req.id, req.query);
|
|
127
|
+
const now = deps.now();
|
|
128
|
+
|
|
129
|
+
const hit = deps.cache.get(key);
|
|
130
|
+
if (hit && isFresh(hit, ttl, now)) {
|
|
131
|
+
return { status: 200, body: hit.value, cached: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const headers = await upstreamHeaders(binding, deps);
|
|
135
|
+
const isDetail = req.id != null;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// Resolver transport: no HTTP — call the injected resolver, normalise, cache.
|
|
139
|
+
if (binding.transport === "resolver") {
|
|
140
|
+
const resolver = deps.resolvers?.[binding.resolver];
|
|
141
|
+
if (!resolver) {
|
|
142
|
+
return {
|
|
143
|
+
status: 501,
|
|
144
|
+
body: { detail: `No resolver "${binding.resolver}" registered for ${req.app}.${req.model}.` },
|
|
145
|
+
cached: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const data = await resolver({ app: req.app, model: req.model, id: req.id, query: req.query });
|
|
149
|
+
let value: unknown;
|
|
150
|
+
if (isDetail) {
|
|
151
|
+
value = toDetail(data, pk, binding.fields);
|
|
152
|
+
if (value == null) return { status: 404, body: { detail: "Not found" }, cached: false };
|
|
153
|
+
} else {
|
|
154
|
+
const { page, pageSize } = pageParams(req.query);
|
|
155
|
+
value = toListEnvelope(data, { page, pageSize, primaryKey: pk, fields: binding.fields });
|
|
156
|
+
}
|
|
157
|
+
deps.cache.set(key, { value, storedAt: now });
|
|
158
|
+
return { status: 200, body: value, cached: false };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build the upstream request for this transport + list/detail shape.
|
|
162
|
+
const upstream =
|
|
163
|
+
binding.transport === "rest"
|
|
164
|
+
? isDetail
|
|
165
|
+
? restDetailRequest(binding, req.id!, headers)
|
|
166
|
+
: restListRequest(binding, req.query, headers)
|
|
167
|
+
: isDetail
|
|
168
|
+
? graphqlDetailRequest(binding, req.id!, headers)
|
|
169
|
+
: graphqlListRequest(binding, req.query, headers);
|
|
170
|
+
|
|
171
|
+
const result = await callUpstream(upstream, deps, hit?.etag);
|
|
172
|
+
|
|
173
|
+
// A revalidated ETag: refresh the stored timestamp and serve the cache.
|
|
174
|
+
if (result.status === 304 && hit) {
|
|
175
|
+
deps.cache.set(key, { ...hit, storedAt: now });
|
|
176
|
+
return { status: 200, body: hit.value, cached: true };
|
|
177
|
+
}
|
|
178
|
+
if (result.status >= 400) {
|
|
179
|
+
return { status: result.status, body: result.body ?? { detail: "Upstream error." }, cached: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Extract the item(s) for this transport and normalise into the contract.
|
|
183
|
+
let value: unknown;
|
|
184
|
+
if (isDetail) {
|
|
185
|
+
const item =
|
|
186
|
+
binding.transport === "rest"
|
|
187
|
+
? result.body
|
|
188
|
+
: graphqlDetailItem(binding, result.body);
|
|
189
|
+
value = toDetail(item, pk, binding.fields);
|
|
190
|
+
if (value == null) return { status: 404, body: { detail: "Not found" }, cached: false };
|
|
191
|
+
} else {
|
|
192
|
+
const items =
|
|
193
|
+
binding.transport === "rest"
|
|
194
|
+
? restListItems(binding, result.body)
|
|
195
|
+
: graphqlListItems(binding, result.body);
|
|
196
|
+
const { page, pageSize } = pageParams(req.query);
|
|
197
|
+
value = toListEnvelope(items, { page, pageSize, primaryKey: pk, fields: binding.fields });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
deps.cache.set(key, { value, storedAt: now, etag: result.etag });
|
|
201
|
+
return { status: 200, body: value, cached: false };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// A stale cache entry is better than a hard failure while iterating.
|
|
204
|
+
if (hit) return { status: 200, body: hit.value, cached: true };
|
|
205
|
+
return { status: 502, body: { detail: (err as Error).message }, cached: false };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rastack` external-API proxy core.
|
|
3
|
+
*
|
|
4
|
+
* Serves external REST/GraphQL APIs (GitHub, Expo, AWS) over the rastack
|
|
5
|
+
* `/api/{app}/v1/{model}/` contract so the *normal typed hooks* consume them
|
|
6
|
+
* unchanged: caches responses, injects auth, and normalises upstream payloads
|
|
7
|
+
* into the DRF envelope. Transport-agnostic and effect-injected, so the identical
|
|
8
|
+
* core runs in the deployed Lambda and in the mini service inside `rastack dev`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export * from "./types";
|
|
12
|
+
export * from "./cache";
|
|
13
|
+
export * from "./envelope";
|
|
14
|
+
export * from "./rest";
|
|
15
|
+
export * from "./graphql";
|
|
16
|
+
export * from "./route";
|
|
17
|
+
export * from "./service";
|
|
18
|
+
export * from "./lambda";
|
|
19
|
+
export {
|
|
20
|
+
handleExternalRequest,
|
|
21
|
+
type RastackRequest,
|
|
22
|
+
type RastackResponse,
|
|
23
|
+
type HandlerDeps,
|
|
24
|
+
} from "./handler";
|
|
25
|
+
export { getPath } from "./util";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AWS Lambda adapter — the deployed twin of the `rastack dev` mini service.
|
|
3
|
+
*
|
|
4
|
+
* Both are thin shells over the same pure {@link handleExternalRequest} core:
|
|
5
|
+
* parse the incoming request into a {@link RastackRequest}, run the handler,
|
|
6
|
+
* serialise the result. The differences are injected, not branched:
|
|
7
|
+
* • **auth** — dev reads `GITHUB_TOKEN` from the environment; the Lambda's
|
|
8
|
+
* `deps.authHeaders` reads AWS Secrets Manager (the app supplies that
|
|
9
|
+
* closure, so no AWS SDK import leaks into the framework).
|
|
10
|
+
* • **cache** — dev uses an in-process `Map`; the Lambda can pass a
|
|
11
|
+
* DynamoDB/ElastiCache-backed {@link ProxyCache} for cross-invocation hits.
|
|
12
|
+
*
|
|
13
|
+
* Authn/z is handled *in front* by an API Gateway Cognito authorizer, so by the
|
|
14
|
+
* time an event reaches here the caller's JWT is already verified; this adapter
|
|
15
|
+
* only serves data. It accepts both API Gateway payload formats (HTTP API v2 and
|
|
16
|
+
* REST/proxy v1), so it drops into either integration.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { handleExternalRequest, type HandlerDeps } from "./handler";
|
|
20
|
+
import { parseApiPath } from "./route";
|
|
21
|
+
import type { ResourceBinding } from "./types";
|
|
22
|
+
|
|
23
|
+
/** The subset of an API Gateway event this adapter reads (v1 and v2 tolerant). */
|
|
24
|
+
export interface ApiGatewayEvent {
|
|
25
|
+
/** HTTP API (payload v2). */
|
|
26
|
+
rawPath?: string;
|
|
27
|
+
rawQueryString?: string;
|
|
28
|
+
requestContext?: { http?: { method?: string } };
|
|
29
|
+
/** REST API / proxy (payload v1). */
|
|
30
|
+
path?: string;
|
|
31
|
+
httpMethod?: string;
|
|
32
|
+
queryStringParameters?: Record<string, string | undefined> | null;
|
|
33
|
+
multiValueQueryStringParameters?: Record<string, string[] | undefined> | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ApiGatewayResult {
|
|
37
|
+
statusCode: number;
|
|
38
|
+
headers: Record<string, string>;
|
|
39
|
+
body: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const JSON_HEADERS = {
|
|
43
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
44
|
+
"Cache-Control": "no-store",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** Reconstruct the request URL (path + query) from either payload format. */
|
|
48
|
+
export function eventToUrl(event: ApiGatewayEvent): string {
|
|
49
|
+
if (event.rawPath != null) {
|
|
50
|
+
const qs = event.rawQueryString ? `?${event.rawQueryString}` : "";
|
|
51
|
+
return `${event.rawPath}${qs}`;
|
|
52
|
+
}
|
|
53
|
+
const path = event.path ?? "/";
|
|
54
|
+
const search = new URLSearchParams();
|
|
55
|
+
const multi = event.multiValueQueryStringParameters;
|
|
56
|
+
if (multi) {
|
|
57
|
+
for (const [key, values] of Object.entries(multi)) {
|
|
58
|
+
for (const v of values ?? []) if (v != null) search.append(key, v);
|
|
59
|
+
}
|
|
60
|
+
} else if (event.queryStringParameters) {
|
|
61
|
+
for (const [key, v] of Object.entries(event.queryStringParameters)) {
|
|
62
|
+
if (v != null) search.append(key, v);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const qs = search.toString();
|
|
66
|
+
return `${path}${qs ? `?${qs}` : ""}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The HTTP method from either payload format (defaults to GET). */
|
|
70
|
+
export function eventMethod(event: ApiGatewayEvent): string {
|
|
71
|
+
return (event.requestContext?.http?.method ?? event.httpMethod ?? "GET").toUpperCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Handle one API Gateway event: parse → run the proxy core → serialise. Returns
|
|
76
|
+
* a proxy-integration result (`statusCode`/`headers`/`body`) for either format.
|
|
77
|
+
*/
|
|
78
|
+
export async function handleLambdaEvent(
|
|
79
|
+
event: ApiGatewayEvent,
|
|
80
|
+
bindings: ResourceBinding[],
|
|
81
|
+
deps: HandlerDeps,
|
|
82
|
+
): Promise<ApiGatewayResult> {
|
|
83
|
+
// Reads are open (viewing is public; the authorizer gated the request); the
|
|
84
|
+
// proxy only serves GET. Anything else is a fast, cheap rejection.
|
|
85
|
+
if (eventMethod(event) !== "GET") {
|
|
86
|
+
return {
|
|
87
|
+
statusCode: 405,
|
|
88
|
+
headers: JSON_HEADERS,
|
|
89
|
+
body: JSON.stringify({ detail: "Method not allowed" }),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const req = parseApiPath(eventToUrl(event));
|
|
94
|
+
if (!req) {
|
|
95
|
+
return {
|
|
96
|
+
statusCode: 404,
|
|
97
|
+
headers: JSON_HEADERS,
|
|
98
|
+
body: JSON.stringify({ detail: "Not an /api/{app}/v1/{model}/ route." }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await handleExternalRequest(req, bindings, deps);
|
|
103
|
+
return {
|
|
104
|
+
statusCode: result.status,
|
|
105
|
+
headers: { ...JSON_HEADERS, "X-Rastack-Cache": result.cached ? "hit" : "miss" },
|
|
106
|
+
body: JSON.stringify(result.body),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the upstream HTTP request for a REST-backed resource, and pull the item
|
|
3
|
+
* array out of the response body. Pure string/URL work — no network — so it is
|
|
4
|
+
* unit-tested directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RestBinding } from "./types";
|
|
8
|
+
import { firstParam, getPath } from "./util";
|
|
9
|
+
|
|
10
|
+
export interface UpstreamRequest {
|
|
11
|
+
url: string;
|
|
12
|
+
method: string;
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The rastack→upstream default query-param names, before a binding overrides them. */
|
|
17
|
+
const DEFAULT_QUERY_MAP = {
|
|
18
|
+
page: "page",
|
|
19
|
+
page_size: "per_page",
|
|
20
|
+
search: "q",
|
|
21
|
+
order_by: "sort",
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/** Build the upstream list request from a rastack list query. */
|
|
25
|
+
export function restListRequest(
|
|
26
|
+
binding: RestBinding,
|
|
27
|
+
query: Record<string, string[]>,
|
|
28
|
+
headers: Record<string, string>,
|
|
29
|
+
): UpstreamRequest {
|
|
30
|
+
const map = { ...DEFAULT_QUERY_MAP, ...(binding.queryMap ?? {}) };
|
|
31
|
+
const search = new URLSearchParams();
|
|
32
|
+
for (const key of ["page", "page_size", "search", "order_by"] as const) {
|
|
33
|
+
const value = firstParam(query, key);
|
|
34
|
+
if (value != null && value !== "") search.set(map[key], value);
|
|
35
|
+
}
|
|
36
|
+
const qs = search.toString();
|
|
37
|
+
return {
|
|
38
|
+
url: `${binding.baseUrl}${binding.listPath}${qs ? `?${qs}` : ""}`,
|
|
39
|
+
method: "GET",
|
|
40
|
+
headers,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build the upstream detail request. Throws if the binding has no detail path. */
|
|
45
|
+
export function restDetailRequest(
|
|
46
|
+
binding: RestBinding,
|
|
47
|
+
id: string,
|
|
48
|
+
headers: Record<string, string>,
|
|
49
|
+
): UpstreamRequest {
|
|
50
|
+
if (!binding.detailPath) {
|
|
51
|
+
throw new Error(`REST binding ${binding.app}.${binding.model} has no detailPath.`);
|
|
52
|
+
}
|
|
53
|
+
const path = binding.detailPath.replace(/\{id\}/g, encodeURIComponent(id));
|
|
54
|
+
return { url: `${binding.baseUrl}${path}`, method: "GET", headers };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract the item array from a REST list body (honours `itemsPath`). */
|
|
58
|
+
export function restListItems(binding: RestBinding, body: unknown): unknown {
|
|
59
|
+
return getPath(body, binding.itemsPath);
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the rastack REST path both the mini `rastack dev` service and the
|
|
3
|
+
* deployed Lambda receive into a {@link RastackRequest}. The contract is fixed:
|
|
4
|
+
*
|
|
5
|
+
* /api/{app}/v1/{model}/ → list
|
|
6
|
+
* /api/{app}/v1/{model}/{id}/ → detail
|
|
7
|
+
*
|
|
8
|
+
* Kept pure and separate from the servers so it is tested without a socket.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RastackRequest } from "./handler";
|
|
12
|
+
|
|
13
|
+
/** Parse a request URL (path + optional query) into a rastack request, or null. */
|
|
14
|
+
export function parseApiPath(url: string): RastackRequest | null {
|
|
15
|
+
const [rawPath, rawQuery = ""] = url.split("?");
|
|
16
|
+
const segments = rawPath.split("/").filter(Boolean); // api, {app}, v1, {model}, [id]
|
|
17
|
+
if (segments.length < 4 || segments[0] !== "api" || segments[2] !== "v1") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const app = decodeURIComponent(segments[1]);
|
|
21
|
+
const model = decodeURIComponent(segments[3]);
|
|
22
|
+
const id = segments[4] ? decodeURIComponent(segments[4]) : undefined;
|
|
23
|
+
|
|
24
|
+
const query: Record<string, string[]> = {};
|
|
25
|
+
for (const pair of rawQuery.split("&")) {
|
|
26
|
+
if (!pair) continue;
|
|
27
|
+
const eq = pair.indexOf("=");
|
|
28
|
+
const key = decodeURIComponent(eq === -1 ? pair : pair.slice(0, eq));
|
|
29
|
+
const value = decodeURIComponent(eq === -1 ? "" : pair.slice(eq + 1)).replace(/\+/g, " ");
|
|
30
|
+
(query[key] ??= []).push(value);
|
|
31
|
+
}
|
|
32
|
+
return { app, model, id, query };
|
|
33
|
+
}
|