diff --git a/packages/aws/src/api-gateway.ts b/packages/aws/src/api-gateway.ts index 7d87b8c..f861dd4 100644 --- a/packages/aws/src/api-gateway.ts +++ b/packages/aws/src/api-gateway.ts @@ -1,26 +1,102 @@ -import { registerResource } from "@notation/core"; -import { ApiGatewayHandler } from "./lambda"; +import { AwsResourceGroup } from "./core"; +import { ApiGatewayHandler, fn } from "./lambda"; -export type ApiOptions = { +export type ApiConfig = { name: string; }; -export const api = (apiOpts: ApiOptions) => { - const apiResource = registerResource("api", apiOpts); - return { - get: (path: string, handler: ApiGatewayHandler) => { - registerResource("api-route", { - api: apiResource.id, - service: "aws/api-gateway", - path, - method: "GET", - // @ts-ignore – property exists at runtime - handler: handler.id, - }); +export const api = (config: ApiConfig) => { + const apiGroup = new AwsResourceGroup({ type: "api", config: config }); + + const apiGateway = apiGroup.createResource({ + type: "api-gateway", + }); + + apiGroup.createResource({ + type: "api-gateway/stage", + dependencies: { + routerId: apiGateway.id, }, + }); + + return apiGroup; +}; + +export const router = (apiGroup: ReturnType) => { + const createRouteCallback = + (method: string) => (path: string, handler: ApiGatewayHandler) => { + return route(apiGroup, method, path, handler); + }; + return { + get: createRouteCallback("GET"), + post: createRouteCallback("POST"), + put: createRouteCallback("PUT"), + patch: createRouteCallback("PATCH"), + delete: createRouteCallback("DELETE"), }; }; +export const route = ( + apiGroup: ReturnType, + method: string, + path: string, + handler: ApiGatewayHandler, +) => { + const apiGateway = apiGroup.findResourceByType("api-gateway")!; + + // at compile time becomes infra module + const fnGroup = handler as any as ReturnType; + + const routeGroup = new AwsResourceGroup({ + type: "route", + dependencies: { + router: apiGroup.id, + fn: fnGroup.id, + }, + config: { + service: "aws/api-gateway", + path, + method, + }, + }); + + let integration; + + const lambda = fnGroup.findResourceByType("lambda")!; + const permission = fnGroup.findResourceByType("lambda/permission"); + integration = fnGroup.findResourceByType("lambda/integration"); + + if (!integration) { + integration = fnGroup.createResource({ + type: "lambda/integration", + dependencies: { + apiGatewayId: apiGateway.id, + lambdaId: lambda.id, + }, + }); + } + + if (!permission) { + fnGroup.createResource({ + type: "lambda/permission", + dependencies: { + apiGatewayId: apiGateway.id, + lambdaId: lambda.id, + }, + }); + } + + routeGroup.createResource({ + type: "api-gateway/route", + dependencies: { + apiGatewayId: apiGateway.id, + integrationId: integration.id, + }, + }); + + return routeGroup; +}; + export const json = (result: any) => ({ body: JSON.stringify(result), statusCode: 200, diff --git a/packages/aws/src/core.ts b/packages/aws/src/core.ts new file mode 100644 index 0000000..f8b9397 --- /dev/null +++ b/packages/aws/src/core.ts @@ -0,0 +1,8 @@ +import { ResourceGroup, ResourceGroupOptions } from "@notation/core"; + +export class AwsResourceGroup extends ResourceGroup { + constructor(opts: ResourceGroupOptions) { + super(opts); + this.platform = "aws"; + } +} diff --git a/packages/aws/src/lambda.ts b/packages/aws/src/lambda.ts index cc313f7..09ae67b 100644 --- a/packages/aws/src/lambda.ts +++ b/packages/aws/src/lambda.ts @@ -1,3 +1,4 @@ +import { AwsResourceGroup } from "./core"; import type { Context, APIGatewayProxyEvent, @@ -18,3 +19,27 @@ export type ApiGatewayHandler = ( export const handle = { apiRequest: (handler: ApiGatewayHandler): ApiGatewayHandler => handler, }; + +export const fn = (config: { handler: string }) => { + const functionGroup = new AwsResourceGroup({ type: "function", config }); + + const role = functionGroup.createResource({ + type: "iam/role", + }); + + const policyAttachment = functionGroup.createResource({ + type: "iam/policy-attachment", + dependencies: { + roleId: role.id, + }, + }); + + functionGroup.createResource({ + type: "lambda", + dependencies: { + policyId: policyAttachment.id, + }, + }); + + return functionGroup; +}; diff --git a/packages/aws/test/__snapshots__/api-gateway.test.ts.snap b/packages/aws/test/__snapshots__/api-gateway.test.ts.snap new file mode 100644 index 0000000..49af7a4 --- /dev/null +++ b/packages/aws/test/__snapshots__/api-gateway.test.ts.snap @@ -0,0 +1,303 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`api resource group snapshot 1`] = ` +AwsResourceGroup { + "config": { + "name": "api", + }, + "createResource": [Function], + "dependencies": {}, + "findResourceByType": [Function], + "id": 1, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": {}, + "groupId": 1, + "id": 3, + "type": "api-gateway", + }, + { + "config": {}, + "dependencies": { + "routerId": 3, + }, + "groupId": 1, + "id": 4, + "type": "api-gateway/stage", + }, + ], + "type": "api", +} +`; + +exports[`route resource group snapshot 1`] = ` +AwsResourceGroup { + "config": { + "method": "GET", + "path": "/hello", + "service": "aws/api-gateway", + }, + "createResource": [Function], + "dependencies": { + "fn": 3, + "router": 2, + }, + "findResourceByType": [Function], + "id": 4, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": { + "apiGatewayId": 5, + "integrationId": 10, + }, + "groupId": 4, + "id": 12, + "type": "api-gateway/route", + }, + ], + "type": "route", +} +`; + +exports[`route resource group snapshot 2`] = ` +AwsResourceGroup { + "config": { + "handler": "handler.fn.js", + }, + "createResource": [Function], + "dependencies": {}, + "findResourceByType": [Function], + "id": 3, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": {}, + "groupId": 3, + "id": 7, + "type": "iam/role", + }, + { + "config": {}, + "dependencies": { + "roleId": 7, + }, + "groupId": 3, + "id": 8, + "type": "iam/policy-attachment", + }, + { + "config": {}, + "dependencies": { + "policyId": 8, + }, + "groupId": 3, + "id": 9, + "type": "lambda", + }, + { + "config": {}, + "dependencies": { + "apiGatewayId": 5, + "lambdaId": 9, + }, + "groupId": 3, + "id": 10, + "type": "lambda/integration", + }, + { + "config": {}, + "dependencies": { + "apiGatewayId": 5, + "lambdaId": 9, + }, + "groupId": 3, + "id": 11, + "type": "lambda/permission", + }, + ], + "type": "function", +} +`; + +exports[`route resource group idempotency snapshot 1`] = ` +AwsResourceGroup { + "config": { + "handler": "handler.fn.js", + }, + "createResource": [Function], + "dependencies": {}, + "findResourceByType": [Function], + "id": 6, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": {}, + "groupId": 6, + "id": 15, + "type": "iam/role", + }, + { + "config": {}, + "dependencies": { + "roleId": 15, + }, + "groupId": 6, + "id": 16, + "type": "iam/policy-attachment", + }, + { + "config": {}, + "dependencies": { + "policyId": 16, + }, + "groupId": 6, + "id": 17, + "type": "lambda", + }, + { + "config": {}, + "dependencies": {}, + "groupId": 6, + "id": 18, + "type": "lambda/integration", + }, + { + "config": {}, + "dependencies": {}, + "groupId": 6, + "id": 19, + "type": "lambda/permission", + }, + ], + "type": "function", +} +`; + +exports[`route resource group idempotency snapshot 2`] = ` +AwsResourceGroup { + "config": { + "method": "GET", + "path": "/hello", + "service": "aws/api-gateway", + }, + "createResource": [Function], + "dependencies": { + "fn": 6, + "router": 5, + }, + "findResourceByType": [Function], + "id": 7, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": { + "apiGatewayId": 13, + "integrationId": 18, + }, + "groupId": 7, + "id": 20, + "type": "api-gateway/route", + }, + ], + "type": "route", +} +`; + +exports[`route resource group snapshot 3`] = ` +AwsResourceGroup { + "config": { + "method": "GET", + "path": "/hello", + "service": "aws/api-gateway", + }, + "createResource": [Function], + "dependencies": { + "fn": 6, + "router": 5, + }, + "findResourceByType": [Function], + "id": 7, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": { + "apiGatewayId": 13, + "integrationId": 18, + }, + "groupId": 7, + "id": 20, + "type": "api-gateway/route", + }, + ], + "type": "route", +} +`; + +exports[`route resource group snapshot 4`] = ` +AwsResourceGroup { + "config": { + "handler": "handler.fn.js", + }, + "createResource": [Function], + "dependencies": {}, + "findResourceByType": [Function], + "id": 6, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": {}, + "groupId": 6, + "id": 15, + "type": "iam/role", + }, + { + "config": {}, + "dependencies": { + "roleId": 15, + }, + "groupId": 6, + "id": 16, + "type": "iam/policy-attachment", + }, + { + "config": {}, + "dependencies": { + "policyId": 16, + }, + "groupId": 6, + "id": 17, + "type": "lambda", + }, + { + "config": {}, + "dependencies": { + "apiGatewayId": 13, + "lambdaId": 17, + }, + "groupId": 6, + "id": 18, + "type": "lambda/integration", + }, + { + "config": {}, + "dependencies": { + "apiGatewayId": 13, + "lambdaId": 17, + }, + "groupId": 6, + "id": 19, + "type": "lambda/permission", + }, + ], + "type": "function", +} +`; diff --git a/packages/aws/test/__snapshots__/lambda.test.ts.snap b/packages/aws/test/__snapshots__/lambda.test.ts.snap new file mode 100644 index 0000000..cbedd7a --- /dev/null +++ b/packages/aws/test/__snapshots__/lambda.test.ts.snap @@ -0,0 +1,83 @@ +// Bun Snapshot v1, https://goo.gl/fbAQLP + +exports[`fn 1`] = ` +AwsResourceGroup { + "config": { + "handler": "handler.fn.js", + }, + "createResource": [Function], + "dependencies": {}, + "findResourceByType": [Function], + "id": 0, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": {}, + "groupId": 0, + "id": 0, + "type": "iam/role", + }, + { + "config": {}, + "dependencies": { + "roleId": 0, + }, + "groupId": 0, + "id": 1, + "type": "iam/policy-attachment", + }, + { + "config": {}, + "dependencies": { + "policyId": 1, + }, + "groupId": 0, + "id": 2, + "type": "lambda", + }, + ], + "type": "function", +} +`; + +exports[`fn resource group snapshot 1`] = ` +AwsResourceGroup { + "config": { + "handler": "handler.fn.js", + }, + "createResource": [Function], + "dependencies": {}, + "findResourceByType": [Function], + "id": 0, + "platform": "aws", + "resources": [ + { + "config": {}, + "dependencies": {}, + "groupId": 0, + "id": 0, + "type": "iam/role", + }, + { + "config": {}, + "dependencies": { + "roleId": 0, + }, + "groupId": 0, + "id": 1, + "type": "iam/policy-attachment", + }, + { + "config": {}, + "dependencies": { + "policyId": 1, + }, + "groupId": 0, + "id": 2, + "type": "lambda", + }, + ], + "type": "function", +} +`; diff --git a/packages/aws/test/api-gateway.test.ts b/packages/aws/test/api-gateway.test.ts index e6f04fa..e128df9 100644 --- a/packages/aws/test/api-gateway.test.ts +++ b/packages/aws/test/api-gateway.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "bun:test"; -import { json } from "src/api-gateway"; +import { api, route, router, json } from "src/api-gateway"; +import { fn } from "src/lambda"; test("json returns a JSON string and a 200 status code", () => { const payload = { message: "Hello, world!" }; @@ -7,3 +8,47 @@ test("json returns a JSON string and a 200 status code", () => { expect(response.statusCode).toEqual(200); expect(response.body).toEqual(JSON.stringify(payload)); }); + +test("api resource group snapshot", () => { + const apiResourceGroup = api({ name: "api" }); + expect(apiResourceGroup).toMatchSnapshot(); +}); + +test("route resource group snapshot", () => { + const apiResourceGroup = api({ name: "api" }); + const fnResourceGroup = fn({ handler: "handler.fn.js" }); + + const routeResourceGroup = route( + apiResourceGroup, + "GET", + "/hello", + fnResourceGroup as any, + ); + + expect(routeResourceGroup).toMatchSnapshot(); + expect(fnResourceGroup).toMatchSnapshot(); +}); + +test("route resource group idempotency snapshot", () => { + const apiResourceGroup = api({ name: "api" }); + const fnResourceGroup = fn({ handler: "handler.fn.js" }); + + route(apiResourceGroup, "GET", "/hello", fnResourceGroup as any); + const fnResourceGroupSnapshot = JSON.stringify(fnResourceGroup); + route(apiResourceGroup, "POST", "/hello", fnResourceGroup as any); + const fnResourceGroupSnapshot2 = JSON.stringify(fnResourceGroup); + + expect(fnResourceGroupSnapshot).toEqual(fnResourceGroupSnapshot2); +}); + +test("router provides methods for each HTTP verb", () => { + const apiResourceGroup = api({ name: "api" }); + const apiRouter = router(apiResourceGroup); + const handler = fn({ handler: "handler.fn.js" }); + + for (const method of ["GET", "POST", "PUT", "DELETE", "PATCH"]) { + const route = (apiRouter as any)[method.toLowerCase()]("/hello", handler); + expect(route.config.method).toEqual(method); + expect(route.config.path).toEqual("/hello"); + } +}); diff --git a/packages/aws/test/lambda.test.ts b/packages/aws/test/lambda.test.ts index b223670..542973c 100644 --- a/packages/aws/test/lambda.test.ts +++ b/packages/aws/test/lambda.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { handle } from "src/lambda"; +import { handle, fn } from "src/lambda"; test("handlers are identity functions", async () => { const fn = () => ({}); @@ -8,3 +8,8 @@ test("handlers are identity functions", async () => { expect(result).toEqual(fn); } }); + +test("fn resource group snapshot", async () => { + const fnResourceGroup = fn({ handler: "handler.fn.js" }); + expect(fnResourceGroup).toMatchSnapshot(); +}); diff --git a/packages/aws/tsup.config.ts b/packages/aws/tsup.config.ts index 62297b3..0fb00cc 100644 --- a/packages/aws/tsup.config.ts +++ b/packages/aws/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/lambda.ts", "src/api-gateway.ts"], + entry: ["src/core.ts", "src/lambda.ts", "src/api-gateway.ts"], splitting: false, dts: true, clean: true, diff --git a/packages/cli/src/compile.ts b/packages/cli/src/compile.ts index bac410b..3b8c244 100644 --- a/packages/cli/src/compile.ts +++ b/packages/cli/src/compile.ts @@ -3,7 +3,12 @@ import { functionInfraPlugin, functionRuntimePlugin, } from "@notation/esbuild-plugins"; -import { getResources } from "@notation/core"; +import { + getResourceGroups, + getResources, + createMermaidFlowChart, + createMermaidLiveUrl, +} from "@notation/core"; import { glob } from "glob"; import path from "path"; @@ -28,7 +33,12 @@ export async function compileInfra(entryPoint: string) { }); const outFilePath = `dist/infra/${entryPoint.replace("ts", "mjs")}`; await import(path.join(process.cwd(), outFilePath)); - console.log(getResources()); + const resourceGroups = getResourceGroups(); + const resources = getResources(); + const chart = createMermaidFlowChart(resourceGroups, resources); + const chartUrl = createMermaidLiveUrl(chart); + console.log("\nGenerated infrastructure chart:\n"); + console.log(chartUrl); } export async function compileFns(entryPoints: string[]) { diff --git a/packages/core/package.json b/packages/core/package.json index 6f360c9..2604e9d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,5 +7,14 @@ "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts", "dev": "npm run build -- --watch" + }, + "dependencies": { + "@types/pako": "^2.0.2", + "js-base64": "^3.7.5", + "pako": "^2.1.0" + }, + "devDependencies": { + "@types/common-tags": "^1.8.2", + "common-tags": "^1.8.2" } } diff --git a/packages/core/src/chart.ts b/packages/core/src/chart.ts new file mode 100644 index 0000000..c46d672 --- /dev/null +++ b/packages/core/src/chart.ts @@ -0,0 +1,44 @@ +import pako from "pako"; +import { fromUint8Array } from "js-base64"; +import { ResourceGroup, Resource } from "./resource-group"; + +export const createMermaidFlowChart = ( + resourceGroups: ResourceGroup<{}>[], + resources: Resource<{}>[], +): string => { + let mermaidString = "flowchart TD\n"; + let connectionsString = ""; + + resourceGroups.forEach((group) => { + mermaidString += ` subgraph ${group.type}_${group.id}\n`; + group.resources.forEach((resource) => { + mermaidString += ` ${resource.type}_${resource.id}(${resource.type})\n`; + }); + mermaidString += ` end\n`; + + group.resources.forEach((resource) => { + Object.values(resource.dependencies).forEach((depId) => { + const depResource = resources.find((r) => r.id === depId); + if (depResource) { + connectionsString += ` ${resource.type}_${resource.id} --> ${depResource.type}_${depId}\n`; + } + }); + }); + }); + + return `${mermaidString}\n${connectionsString}`; +}; + +export function createMermaidLiveUrl(mermaidCode: string) { + const state = { + code: mermaidCode, + mermaid: JSON.stringify({ theme: "default" }, undefined, 2), + autoSync: true, + updateDiagram: true, + }; + const json = JSON.stringify(state); + const data = new TextEncoder().encode(json); + const compressed = pako.deflate(data, { level: 9 }); + const string = fromUint8Array(compressed, true); + return `https://mermaid.live/view#pako:${string}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7519cb7..464907a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,21 +1,2 @@ -type Resource = { - id: number; - type: string; -} & Record; - -const resources: Resource[] = []; - -export const getResources = () => resources; - -let idCounter = 0; - -export const registerResource = (type: string, opts: Record) => { - const id = idCounter++; - const resource = { id, type, ...opts }; - resources.push(resource); - return resource; -}; - -export const fn = (opts: Record) => { - return registerResource("function", opts); -}; +export * from "./chart"; +export * from "./resource-group"; diff --git a/packages/core/src/resource-group.ts b/packages/core/src/resource-group.ts new file mode 100644 index 0000000..1deff2e --- /dev/null +++ b/packages/core/src/resource-group.ts @@ -0,0 +1,75 @@ +let resourceGroups: ResourceGroup<{}>[] = []; +let resources: Resource[] = []; + +let resourceGroupCounter = 0; +let resourceCounter = 0; + +export type Resource = { + id: number; + groupId: number; + type: string; + dependencies: Record; + config: Config; +}; + +export type ResourceOptions = { + type: string; + dependencies?: Record; + config?: Config; +}; + +export type ResourceGroupOptions = { + type: string; + dependencies?: Record; + config?: Config; +}; + +export class ResourceGroup { + id: number; + type: string; + platform: string; + dependencies: Record; + config: Config; + resources: Resource[]; + + constructor(opts: ResourceGroupOptions) { + const { type, dependencies, config } = opts; + this.id = resourceGroupCounter++; + this.type = type; + this.platform = "core"; + this.dependencies = dependencies || {}; + this.config = config || ({} as Config); + this.resources = []; + resourceGroups.push(this); + return this; + } + + createResource = ( + opts: ResourceOptions, + ) => { + const resource: Resource = { + id: resourceCounter++, + groupId: this.id, + type: opts.type, + dependencies: opts.dependencies || {}, + config: opts.config || ({} as ResourceConfig), + }; + resources.push(resource); + this.resources.push(resource); + return resource; + }; + + findResourceByType = (type: string) => { + return this.resources.find((r) => r.type === type); + }; +} + +export const getResourceGroups = () => resourceGroups; +export const getResources = () => resources; + +export const reset = () => { + resources = []; + resourceGroups = []; + resourceCounter = 0; + resourceGroupCounter = 0; +}; diff --git a/packages/core/test/chart.test.ts b/packages/core/test/chart.test.ts new file mode 100644 index 0000000..7614248 --- /dev/null +++ b/packages/core/test/chart.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "bun:test"; +import { stripIndent } from "common-tags"; +import { createMermaidFlowChart, createMermaidLiveUrl } from "src/chart"; +import { ResourceGroup, Resource } from "src/resource-group"; + +it("should create a mermaid flowchart string", () => { + const { resourceGroups, resources } = getFixture(); + const chart = createMermaidFlowChart(resourceGroups, resources); + + const expected = stripIndent` + flowchart TD + subgraph GroupTypeA_0 + ResourceTypeA_0(ResourceTypeA) + ResourceTypeB_1(ResourceTypeB) + end + subgraph GroupTypeB_1 + ResourceTypeC_2(ResourceTypeC) + end + + ResourceTypeB_1 --> ResourceTypeA_0`; + + expect(chart).toBe(expected + "\n"); +}); + +it("should create a mermaid live URL from given code", () => { + const mermaidCode = "flowchart TD\nA --> B"; + const result = createMermaidLiveUrl(mermaidCode); + const expected = + "https://mermaid.live/view#pako:eNolyksKhDAQRdGtFG-sG8igQXEH9rAmRVJ-wCRNqNCIuHcDzi6Xc8HnoHBYjvz3mxSj78RpoL7_0IgOUUuUPTRxcSJi2KZRGa5l0EXqYQxOd6NSLc9n8nBWqnaovyCm0y5rkfjO-wHolSVC"; + expect(result).toBe(expected); +}); + +function getFixture() { + const resourceGroups = [ + { + id: 0, + type: "GroupTypeA", + config: {}, + resources: [ + { + id: 0, + groupId: 0, + config: {}, + type: "ResourceTypeA", + dependencies: {}, + }, + { + id: 1, + groupId: 0, + config: {}, + type: "ResourceTypeB", + dependencies: { dep1: 0 }, + }, + ], + }, + { + id: 1, + type: "GroupTypeB", + config: {}, + resources: [ + { + id: 2, + groupId: 1, + config: {}, + type: "ResourceTypeC", + dependencies: {}, + }, + ], + }, + ] as ResourceGroup<{}>[]; + + const resources = [ + { + id: 0, + groupId: 0, + config: {}, + type: "ResourceTypeA", + dependencies: {}, + }, + { + id: 1, + groupId: 0, + config: {}, + type: "ResourceTypeB", + dependencies: { dep1: 0 }, + }, + { + id: 2, + groupId: 1, + config: {}, + type: "ResourceTypeC", + dependencies: {}, + }, + ] as Resource<{}>[]; + + return { resourceGroups, resources }; +} diff --git a/packages/core/test/index.d.ts b/packages/core/test/index.d.ts new file mode 100644 index 0000000..22f6a66 --- /dev/null +++ b/packages/core/test/index.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/core/test/resource-group.ts b/packages/core/test/resource-group.ts new file mode 100644 index 0000000..5221bab --- /dev/null +++ b/packages/core/test/resource-group.ts @@ -0,0 +1,85 @@ +import { beforeEach, expect, it } from "bun:test"; +import { + ResourceGroup, + getResourceGroups, + getResources, + reset, +} from "src/resource-group"; + +beforeEach(() => { + reset(); +}); + +it("creates a resource group", () => { + const resourceGroup = new ResourceGroup({ type: "test" }); + expect(resourceGroup.id).toBe(0); + expect(resourceGroup.type).toBe("test"); + expect(resourceGroup.platform).toBe("core"); + expect(resourceGroup.resources).toEqual([]); +}); + +it("stores resource groups in the global array", () => { + new ResourceGroup({ type: "test" }); + new ResourceGroup({ type: "test2" }); + + expect(getResourceGroups()).toHaveLength(2); + expect(getResourceGroups()[0].type).toBe("test"); + expect(getResourceGroups()[1].type).toBe("test2"); +}); + +it("creates a resource within a group", () => { + const resourceGroup = new ResourceGroup({ type: "testGroup" }); + const resource = resourceGroup.createResource({ type: "testResource" }); + + expect(resource.id).toBe(0); + expect(resource.type).toBe("testResource"); + expect(resource.groupId).toBe(resourceGroup.id); + expect(resourceGroup.resources).toContain(resource); +}); + +it("stores resources in the global array", () => { + const resourceGroup = new ResourceGroup({ type: "testGroup" }); + resourceGroup.createResource({ type: "testResource1" }); + resourceGroup.createResource({ type: "testResource2" }); + + expect(getResources()).toHaveLength(2); + expect(getResources()[0].type).toBe("testResource1"); + expect(getResources()[1].type).toBe("testResource2"); +}); + +it("finds a resource by type within a group", () => { + const resourceGroup = new ResourceGroup({ type: "testGroup" }); + const resource = resourceGroup.createResource({ type: "testResource" }); + + expect(resourceGroup.findResourceByType("testResource")).toBe(resource); + expect(resourceGroup.findResourceByType("nonexistentType")).toBeUndefined(); +}); + +it("increments resource group IDs", () => { + const rg1 = new ResourceGroup({ type: "group1" }); + const rg2 = new ResourceGroup({ type: "group2" }); + + expect(rg1.id).toBe(0); + expect(rg2.id).toBe(1); +}); + +it("increments resource IDs globally", () => { + const rg1 = new ResourceGroup({ type: "group1" }); + const r1 = rg1.createResource({ type: "resource1" }); + const rg2 = new ResourceGroup({ type: "group2" }); + const r2 = rg2.createResource({ type: "resource2" }); + const r3 = rg1.createResource({ type: "resource3" }); + + expect(r1.id).toBe(0); + expect(r2.id).toBe(1); + expect(r3.id).toBe(2); +}); + +it("references resources within groups", () => { + const rg1 = new ResourceGroup({ type: "group1" }); + const r1 = rg1.createResource({ type: "resource1" }); + const r2 = rg1.createResource({ type: "resource2" }); + + expect(getResources()).toContain(r1); + expect(getResources()).toContain(r2); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 6b7962d..b667b19 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "tsconfig/base.json" + "extends": "tsconfig/base.json", + "compilerOptions": { + "baseUrl": "." + } } diff --git a/packages/esbuild-plugins/package.json b/packages/esbuild-plugins/package.json index cdcdfdf..a005361 100644 --- a/packages/esbuild-plugins/package.json +++ b/packages/esbuild-plugins/package.json @@ -14,7 +14,6 @@ }, "devDependencies": { "@types/common-tags": "^1.8.2", - "common-tags": "^1.8.2", - "@notation/aws": "workspace:*" + "common-tags": "^1.8.2" } } diff --git a/packages/esbuild-plugins/src/parsers/parse-fn-module.ts b/packages/esbuild-plugins/src/parsers/parse-fn-module.ts index cbbe1c3..0b1d478 100644 --- a/packages/esbuild-plugins/src/parsers/parse-fn-module.ts +++ b/packages/esbuild-plugins/src/parsers/parse-fn-module.ts @@ -16,12 +16,15 @@ export function parseFnModule(input: string) { const configExport = exports.find((exp) => exp.name === "config"); - const configObjectStr = configExport - ? createSimpleObjectString(configExport.node) - : undefined; + if (!configExport) { + throw new Error("A config object was not exported"); + } + + const { config, configRaw } = createConfigObject(configExport.node); return { - config: configObjectStr, + config, + configRaw, exports: exports.map((exp) => exp.name), }; } @@ -73,11 +76,12 @@ function getExportsFromStatement( return exports; } -function createSimpleObjectString(node: ts.Node) { +function createConfigObject(node: ts.Node) { if (!ts.isObjectLiteralExpression(node)) { throw new Error("'config' is not an object literal."); } - let configObjectStr = "{ "; + let configRaw = "{ "; + let config: Record = {}; node.properties.forEach((prop, index, array) => { if (ts.isPropertyAssignment(prop)) { const key = prop.name.getText(); @@ -89,9 +93,9 @@ function createSimpleObjectString(node: ts.Node) { valueNode.kind === ts.SyntaxKind.FalseKeyword ) { const value = valueNode.getText(); - configObjectStr += `${key}: ${value}${ - index < array.length - 1 ? ", " : " " - }`; + configRaw += `${key}: ${value}`; + configRaw += index === array.length - 1 ? " }" : ", "; + config[key] = JSON.parse(value); } else { throw new Error( `Invalid value type for key '${key}': only numbers, strings, and booleans are allowed.`, @@ -104,7 +108,5 @@ function createSimpleObjectString(node: ts.Node) { } }); - configObjectStr += "}"; - - return configObjectStr; + return { config, configRaw }; } diff --git a/packages/esbuild-plugins/src/plugins/function-infra-plugin.ts b/packages/esbuild-plugins/src/plugins/function-infra-plugin.ts index aff6c5d..c41f842 100644 --- a/packages/esbuild-plugins/src/plugins/function-infra-plugin.ts +++ b/packages/esbuild-plugins/src/plugins/function-infra-plugin.ts @@ -15,11 +15,12 @@ export function functionInfraPlugin(opts: PluginOpts = {}): Plugin { const getFile = withFileCheck(opts.getFile || fsGetFile); const fileContent = await getFile(args.path); const fileName = path.relative(process.cwd(), args.path); - const { config, exports } = parseFnModule(fileContent); + const { config, configRaw, exports } = parseFnModule(fileContent); + const reservedNames = ["preload", "config"]; - let infraCode = `import { fn } from "@notation/core"`; - infraCode = infraCode.concat(`\nconst config = ${config || "{}"};`); + let infraCode = `import { fn } from "@notation/${config.service}"`; + infraCode = infraCode.concat(`\nconst config = ${configRaw};`); for (const handlerName of exports) { if (reservedNames.includes(handlerName)) continue; diff --git a/packages/esbuild-plugins/test/parsers/parse-fn-module.test.ts b/packages/esbuild-plugins/test/parsers/parse-fn-module.test.ts index 5437228..4592e74 100644 --- a/packages/esbuild-plugins/test/parsers/parse-fn-module.test.ts +++ b/packages/esbuild-plugins/test/parsers/parse-fn-module.test.ts @@ -45,17 +45,6 @@ describe("parsing exports", () => { }); describe("parsing config", () => { - it("allows no config to be exported", () => { - const input = ` - export const getNum = handler(() => num); - const num = 123; - `; - - const { config } = parseFnModule(input); - - expect(config).toBeUndefined(); - }); - it("parses primitive properties", () => { const input = ` export const config = { @@ -68,9 +57,12 @@ describe("parsing config", () => { const { config } = parseFnModule(input); - expect(config).toBe( - '{ key1: "value1", key2: 123, key3: true, key4: false }', - ); + expect(config).toEqual({ + key1: "value1", + key2: 123, + key3: true, + key4: false, + }); }); it("does not allow complex property types", () => { @@ -86,6 +78,32 @@ describe("parsing config", () => { } }); + it("throws an error if no config is provided", async () => { + const input = ` + import { handler } from "@notation/aws/api-gateway"; + export const getNum = handler(() => 1); + `; + expect(() => parseFnModule(input)).toThrow( + /A config object was not exported/, + ); + }); + + it("returns a raw config string", () => { + const input = `export const config = { + key1: "value1", + key2: 123, + key3: true, + key4: false + };`; + + const { configRaw } = parseFnModule(input); + console.log(configRaw); + + expect(configRaw).toBe( + `{ key1: "value1", key2: 123, key3: true, key4: false }`, + ); + }); + describe("invalid config values", () => { const invalidInputs = { identifier: "export const config = identifier;", diff --git a/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts b/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts index a8f29b3..3777fa2 100644 --- a/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts +++ b/packages/esbuild-plugins/test/plugins/function-infra-plugin.test.ts @@ -11,12 +11,13 @@ const buildInfra = createBuilder((input) => ({ it("remaps exports", async () => { const input = ` import { handler } from "@notation/aws/api-gateway"; + export const config = { service: "aws/lambda" }; export const getNum = handler(() => 1); `; const expected = stripIndent` - import { fn } from "@notation/core"; - const config = {}; + import { fn } from "@notation/aws/lambda"; + const config = { service: "aws/lambda" }; export const getNum = fn({ fileName: "entry.fn.ts", handler: "getNum", ...config }); `; @@ -30,12 +31,12 @@ it("merges config", async () => { import { FnConfig } from "@notation/aws/lambda"; import { handler } from "@notation/aws/api-gateway"; export const getNum = handler(() => 1); - export const config: FnConfig = { memory: 64 }; + export const config: FnConfig = { service: "aws/lambda", memory: 64 }; `; const expected = stripIndent` - import { fn } from "@notation/core"; - const config = { memory: 64 }; + import { fn } from "@notation/aws/lambda"; + const config = { service: "aws/lambda", memory: 64 }; export const getNum = fn({ fileName: "entry.fn.ts", handler: "getNum", ...config }); `; @@ -53,11 +54,12 @@ it("should strip runtime code", async () => { let num = lib.getNum(); export const getNum = () => num; - export const getDoubleNum = () => num * 2;`; + export const getDoubleNum = () => num * 2; + export const config = { service: "aws/lambda" };`; const expected = stripIndent` - import { fn } from "@notation/core"; - const config = {}; + import { fn } from "@notation/aws/lambda"; + const config = { service: "aws/lambda" }; export const getNum = fn({ fileName: "entry.fn.ts", handler: "getNum", ...config }); export const getDoubleNum = fn({ fileName: "entry.fn.ts", handler: "getDoubleNum", ...config }); `; @@ -66,3 +68,13 @@ it("should strip runtime code", async () => { expect(output).toContain(expected); }); + +// broken in bun 1.0.7 +it.skip("should fail if no config is exported", async () => { + const input = ` + import { handler } from "@notation/aws/api-gateway"; + export const getNum = handler(() => 1); + `; + + expect(buildInfra(input)).rejects.toThrow(/No config object was exported/); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac0f902..b470282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,11 +51,22 @@ importers: glob: 10.3.10 packages/core: - specifiers: {} + specifiers: + '@types/common-tags': ^1.8.2 + '@types/pako': ^2.0.2 + common-tags: ^1.8.2 + js-base64: ^3.7.5 + pako: ^2.1.0 + dependencies: + '@types/pako': 2.0.2 + js-base64: 3.7.5 + pako: 2.1.0 + devDependencies: + '@types/common-tags': 1.8.2 + common-tags: 1.8.2 packages/esbuild-plugins: specifiers: - '@notation/aws': workspace:* '@types/common-tags': ^1.8.2 common-tags: ^1.8.2 esbuild: ^0.19.3 @@ -64,7 +75,6 @@ importers: esbuild: 0.19.3 typescript: 5.2.2 devDependencies: - '@notation/aws': link:../aws '@types/common-tags': 1.8.2 common-tags: 1.8.2 @@ -821,6 +831,10 @@ packages: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: false + /@types/pako/2.0.2: + resolution: {integrity: sha512-AtTbzIwhvLMTEUPudP3hxUwNK50DoX3amfVJmmL7WQH5iF3Kfqs8pG1tStsewHqmh75ULmjjldKn/B70D6DNcQ==} + dev: false + /@types/semver/7.5.1: resolution: {integrity: sha512-cJRQXpObxfNKkFAZbJl2yjWtJCqELQIdShsogr1d2MilP8dKD9TE/nEKHkJgUNHdGKCQaf9HbIynuV2csLGVLg==} dev: false @@ -1907,6 +1921,10 @@ packages: engines: {node: '>=10'} dev: false + /js-base64/3.7.5: + resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} + dev: false + /js-tokens/4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} dev: false @@ -2233,6 +2251,10 @@ packages: engines: {node: '>=6'} dev: false + /pako/2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + dev: false + /parse-json/5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} diff --git a/test/compiler.test.app/src/api.ts b/test/compiler.test.app/src/api.ts index a1a84c8..615d945 100644 --- a/test/compiler.test.app/src/api.ts +++ b/test/compiler.test.app/src/api.ts @@ -1,8 +1,8 @@ -import { api } from "@notation/aws/api-gateway"; +import { api, router } from "@notation/aws/api-gateway"; import { getTodos, getTodoCount } from "./todos/todos.fn"; const todoApi = api({ name: "todo-api" }); +const todoRouter = router(todoApi); -todoApi.get("/todos", getTodos); -todoApi.get("/todos/count", getTodoCount); -todoApi.get("/todos/count2", getTodoCount); +todoRouter.get("/todos", getTodos); +todoRouter.get("/todos/count", getTodoCount);