Skip to content

Commit c502062

Browse files
committed
feat: add openapi geneneration
1 parent b881f11 commit c502062

37 files changed

+2007
-601
lines changed

.github/workflows/package.yml

+15-1
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,27 @@ on:
1616
type: boolean
1717

1818
jobs:
19+
optimize_ci:
20+
runs-on: ubuntu-latest
21+
outputs:
22+
skip: ${{ steps.check_skip.outputs.skip }}
23+
steps:
24+
- name: Optimize CI
25+
id: check_skip
26+
uses: withgraphite/graphite-ci-action@main
27+
with:
28+
graphite_token: ${{ secrets.GRAPHITE_CI_OPTIMIZER_TOKEN }}
29+
1930
typescript:
31+
needs: [optimize_ci]
32+
if: needs.optimize_ci.outputs.skip == 'false'
2033
uses: skyleague/node-standards/.github/workflows/reusable-typescript.yml@main
2134
secrets:
2235
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
2336

2437
release:
25-
needs: [typescript]
38+
needs: [typescript,optimize_ci]
39+
if: needs.optimize_ci.outputs.skip == 'false'
2640
uses: skyleague/node-standards/.github/workflows/reusable-release.yml@main
2741
with:
2842
build_artifact_name: ${{ needs.typescript.outputs.artifact-name }}

package-lock.json

+1,173-304
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,17 @@
3232
"dependencies": {
3333
"@aws-sdk/client-iam": "^3.614.0",
3434
"@aws-sdk/client-lambda": "^3.614.0",
35+
"@skyleague/axioms": "^4.5.2",
3536
"@skyleague/esbuild-lambda": "^5.3.2",
36-
"@skyleague/event-horizon": "^9.0.0",
37+
"@skyleague/event-horizon": "^10.0.0",
3738
"aws-iot-device-sdk-v2": "^1.20.0",
3839
"camelcase": "^8.0.0",
3940
"find-root": "^1.1.0",
4041
"globby": "^14.0.2",
4142
"js-yaml": "^4.1.0",
4243
"pino-pretty": "^11.2.1",
4344
"tsx": "^4.16.2",
45+
"yaml": "^2.5.0",
4446
"yargs": "^17.7.2"
4547
},
4648
"devDependencies": {

src/commands/dev/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export async function handler(argv: ReturnType<typeof builder>['argv']): Promise
9494
{ ...options, fnDir: [], clean: false },
9595
{
9696
fnDirs: [],
97-
stacks: !hasDebugArtifact ? [debugDir] : [],
97+
stacks: buildRemoteLambdaArtifact ? [debugDir] : [],
9898
preBuild,
9999
},
100100
)

src/commands/openapi/index.ts

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import { isRight, whenRight, whenRights } from '@skyleague/axioms'
4+
import { listLambdaHandlers } from '@skyleague/esbuild-lambda'
5+
import type { HTTPHandler, RequestAuthorizerEventHandler } from '@skyleague/event-horizon'
6+
import { openapiFromHandlers } from '@skyleague/event-horizon/spec'
7+
import { globby } from 'globby'
8+
import yaml from 'yaml'
9+
import type { Argv } from 'yargs'
10+
import { StarChartHandler } from '../../definition/definition.type.js'
11+
import type { HttpTrigger } from '../../definition/events/http.type.js'
12+
import { rootDirectory } from '../../lib/constants.js'
13+
14+
export function builder(yargs: Argv) {
15+
return yargs
16+
.option('output', {
17+
type: 'string',
18+
})
19+
.option('cwd', {
20+
type: 'string',
21+
default: rootDirectory,
22+
coerce: (cwd) => path.join(process.cwd(), cwd),
23+
})
24+
}
25+
26+
export async function handler(argv: ReturnType<typeof builder>['argv']): Promise<void> {
27+
const { buildDir: _buildDir, artifactDir: _artifactDir, output, cwd } = await argv
28+
const fnDirs = ['src/**/functions']
29+
30+
const stacks = await globby(fnDirs, { cwd: cwd, onlyDirectories: true })
31+
const handlerDrectories = (
32+
await Promise.all(stacks.flatMap(async (fnDir) => listLambdaHandlers(path.join(cwd, fnDir))))
33+
).flat()
34+
35+
// for each handler load the corresponding handler.yml file
36+
const handlers = await Promise.all(
37+
handlerDrectories.map(async (handler) => {
38+
const yamlHandler = `${handler}/handler.yml`
39+
if (!(await fs.stat(yamlHandler)).isFile()) {
40+
return { left: 'File not found' }
41+
}
42+
43+
const content = await fs.readFile(yamlHandler)
44+
const handlerContent = yaml.parse(content.toString())
45+
const eitherHandler = StarChartHandler.parse(handlerContent)
46+
if ('right' in eitherHandler) {
47+
const definition = eitherHandler.right
48+
const handlerFile = path.join(handler, 'index.ts')
49+
50+
if (!(await fs.stat(handlerFile)).isFile()) {
51+
return { left: 'Handler file not found' }
52+
}
53+
54+
return {
55+
right: {
56+
definition,
57+
handler: (await import(handler)).handler,
58+
directory: handler,
59+
},
60+
}
61+
}
62+
return eitherHandler
63+
}),
64+
)
65+
const securitySchemes = whenRights(handlers, (xs) => {
66+
return {
67+
right: xs
68+
.filter((x) => 'request' in x.handler && x.definition.securitySchemes !== undefined)
69+
.map((x) => {
70+
const securitySchemes = x.definition.securitySchemes
71+
const definition = x.handler.request as RequestAuthorizerEventHandler
72+
return {
73+
...definition.security,
74+
...securitySchemes,
75+
}
76+
}),
77+
}
78+
})
79+
const httpHandlers = whenRights(handlers, (xs) => {
80+
return {
81+
right: xs
82+
.filter((x) => 'http' in x.handler)
83+
.flatMap((x) => {
84+
const definition = x.handler.http as HTTPHandler
85+
const httpEvents = x.definition.events?.filter((e): e is HttpTrigger => 'http' in e && e.http !== undefined)
86+
87+
return (
88+
httpEvents?.map((e, i) => ({
89+
directory: `${x.directory}-${i}`,
90+
handler: {
91+
...e.http,
92+
http: {
93+
...definition,
94+
...e.http,
95+
},
96+
},
97+
})) ?? []
98+
)
99+
}),
100+
}
101+
})
102+
const packageJson = JSON.parse((await fs.readFile(path.join(cwd, 'package.json'))).toString())
103+
104+
const openapi = whenRight(httpHandlers, (xs) => {
105+
const securitySchemesComponent = isRight(securitySchemes)
106+
? { securitySchemes: Object.assign({}, ...securitySchemes.right) }
107+
: {}
108+
const openapi = openapiFromHandlers(Object.fromEntries(xs.map((x) => [x.directory, x.handler])), {
109+
info: { title: packageJson.name, version: packageJson.version },
110+
components: {
111+
...securitySchemesComponent,
112+
},
113+
})
114+
return { right: openapi }
115+
})
116+
117+
if (!isRight(openapi)) {
118+
throw new Error(JSON.stringify(openapi.left))
119+
}
120+
121+
const content = JSON.stringify(openapi.right, null, 2)
122+
if (output !== undefined) {
123+
await fs.writeFile(output, content)
124+
} else {
125+
console.log(content)
126+
}
127+
}
128+
129+
export default {
130+
command: 'openapi',
131+
describe: 'Build OpenAPI',
132+
builder,
133+
handler,
134+
}

src/definition/authorizer.schema.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { $const, $enum, $object, $optional, $string, $union } from '@skyleague/therefore'
2+
3+
export const apiSecurityScheme = $object({
4+
description: $optional($string().describe('The description of the security scheme.').optional()),
5+
in: $enum(['header', 'query', 'cookie']).describe('The location of the security scheme.'),
6+
name: $string().describe('The name of the security scheme.'),
7+
type: $const('apiKey').describe('The type of the security scheme.'),
8+
})
9+
10+
export const httpSecurityScheme = $object({
11+
bearerFormat: $optional($string().describe('The format of the bearer token.').optional()),
12+
description: $optional($string().describe('The description of the security scheme.').optional()),
13+
scheme: $string().describe('The scheme of the security scheme.'),
14+
type: $const('http').describe('The type of the security scheme.'),
15+
})
16+
17+
export const securityScheme = $union([apiSecurityScheme, httpSecurityScheme])

src/definition/authorizer.type.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Generated by @skyleague/therefore@v1.0.0-local
3+
* Do not manually touch this
4+
*/
5+
/* eslint-disable */
6+
7+
export interface ApiSecurityScheme {
8+
/**
9+
* The description of the security scheme.
10+
*/
11+
description?: string | undefined
12+
/**
13+
* The location of the security scheme.
14+
*/
15+
in: 'header' | 'query' | 'cookie'
16+
/**
17+
* The name of the security scheme.
18+
*/
19+
name: string
20+
/**
21+
* The type of the security scheme.
22+
*/
23+
type: 'apiKey'
24+
}
25+
26+
export interface HttpSecurityScheme {
27+
/**
28+
* The format of the bearer token.
29+
*/
30+
bearerFormat?: string | undefined
31+
/**
32+
* The description of the security scheme.
33+
*/
34+
description?: string | undefined
35+
/**
36+
* The scheme of the security scheme.
37+
*/
38+
scheme: string
39+
/**
40+
* The type of the security scheme.
41+
*/
42+
type: 'http'
43+
}
44+
45+
export type SecurityScheme = ApiSecurityScheme | HttpSecurityScheme

src/definition/definition.schema.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { securityScheme } from './authorizer.schema.js'
12
import { events } from './events/index.schema.js'
23
import { scheduledTriggerEntry } from './events/scheduled.schema.js'
34
import { publishes } from './publishes/index.schema.js'
@@ -21,10 +22,12 @@ export const starChartHandler = $object({
2122
resources: $ref(resources).optional(),
2223

2324
inlinePolicies: $string().array().optional(),
24-
runtime: $enum(['nodejs16.x', 'nodejs18.x', 'nodejs20.x', 'python3.8', 'python3.9', 'python3.10']).optional(),
25+
runtime: $enum(['nodejs18.x', 'nodejs20.x', 'python3.8', 'python3.9', 'python3.10']).optional(),
2526
memorySize: $number().optional(),
2627
timeout: $number().optional(),
2728
vpcConfig: $string().optional(),
29+
30+
securitySchemes: $record(securityScheme).optional().describe('The security schemes to use for the route.'),
2831
})
2932
.describe('The definition of a Star Chart handler.')
3033
.validator({ compile: false })

src/definition/definition.type.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
/* eslint-disable */
66

7+
import type { SecurityScheme } from './authorizer.type.js'
78
import type { Events } from './events/index.type.js'
89
import type { Publishes } from './publishes/index.type.js'
910
import type { Resources } from './resources/index.type.js'
@@ -94,10 +95,18 @@ export interface StarChartHandler {
9495
publishes?: Publishes | undefined
9596
resources?: Resources | undefined
9697
inlinePolicies?: string[] | undefined
97-
runtime?: 'nodejs16.x' | 'nodejs18.x' | 'nodejs20.x' | 'python3.8' | 'python3.9' | 'python3.10' | undefined
98+
runtime?: 'nodejs18.x' | 'nodejs20.x' | 'python3.8' | 'python3.9' | 'python3.10' | undefined
9899
memorySize?: number | undefined
99100
timeout?: number | undefined
100101
vpcConfig?: string | undefined
102+
/**
103+
* The security schemes to use for the route.
104+
*/
105+
securitySchemes?:
106+
| {
107+
[k: string]: SecurityScheme | undefined
108+
}
109+
| undefined
101110
}
102111

103112
export const StarChartHandler = {

src/definition/events/http.schema.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { $enum, $object, $optional, $string } from '@skyleague/therefore'
1+
import { $enum, $object, $optional, $record, $string } from '@skyleague/therefore'
2+
3+
export const securityRequirement = $record($string().describe('The name of the security scheme.'))
24

35
export const httpTrigger = $object({
46
http: $object({
57
method: $enum(['get', 'post', 'put', 'delete', 'patch', 'options', 'head']),
68
path: $string().describe('The HTTP path for the route. Must start with / and must not end with /.'),
79
authorizer: $optional($string().describe('The name of the authorizer to use for the route.').optional()),
10+
security: securityRequirement.optional().describe('The security requirements to use for the route.'),
811
}).describe('Subscribes to an HTTP route.'),
912
})

src/definition/events/http.type.ts

+8
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,13 @@ export interface HttpTrigger {
1818
* The name of the authorizer to use for the route.
1919
*/
2020
authorizer?: string | undefined
21+
/**
22+
* The security requirements to use for the route.
23+
*/
24+
security?: SecurityRequirement | undefined
2125
}
2226
}
27+
28+
export interface SecurityRequirement {
29+
[k: string]: string | undefined
30+
}

src/definition/events/index.schema.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ import { sqsTrigger } from './sqs.schema.js'
55
import { $ref, $union } from '@skyleague/therefore'
66

77
export const events = $union([$ref(httpTrigger), $ref(sqsTrigger), $ref(scheduledTrigger)])
8-
.describe('The events that will trigger the handler.')
98
.array()
9+
.describe('The events that will trigger the handler.')

src/definition/events/index.type.ts

+3
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ import type { HttpTrigger } from './http.type.js'
88
import type { ScheduledTrigger } from './scheduled.type.js'
99
import type { SqsTrigger } from './sqs.type.js'
1010

11+
/**
12+
* The events that will trigger the handler.
13+
*/
1114
export type Events = (HttpTrigger | SqsTrigger | ScheduledTrigger)[]

0 commit comments

Comments
 (0)