Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(exposition): remove express #525

Merged
merged 5 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .eslintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ overrides:
- always
- null: always
'@typescript-eslint/no-unsafe-argument': warn
'@typescript-eslint/no-non-null-assertion': warn
'@typescript-eslint/no-non-null-assertion': off
'@typescript-eslint/non-nullable-type-assertion-style': warn
'@typescript-eslint/unbound-method': warn

Expand All @@ -80,8 +80,8 @@ overrides:
- files: ['**/features/**/*.ts']
extends: 'plugin:@typescript-eslint/disable-type-checked'
rules:
'@typescript-eslint/prefer-nullish-coalescing': 'off'
'@typescript-eslint/strict-boolean-expressions': 'off'
'@typescript-eslint/prefer-nullish-coalescing': 'off'
'@typescript-eslint/strict-boolean-expressions': 'off'
parserOptions:
project: true
env:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

function store (input, context) {
const { storage, request, accept, meta } = input
const path = request.path
const path = request.url
const claim = request.headers['content-type']

return context.storages[storage].put(path, request, { claim, accept, meta })
Expand Down
2 changes: 1 addition & 1 deletion extensions/exposition/features/octets.entries.feature
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Feature: Accessing entires
Feature: Accessing entries

Scenario: Entries are not accessible by default
Given the annotation:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: echo
version: 0.0.0

operations:
compute:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace: octets
name: tester
version: 0.0.0

storages: octets

Expand Down
3 changes: 0 additions & 3 deletions extensions/exposition/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@
"@toa.io/core": "1.0.0-alpha.3",
"@toa.io/generic": "1.0.0-alpha.3",
"@toa.io/schemas": "1.0.0-alpha.3",
"@toa.io/streams": "1.0.0-alpha.3",
"bcryptjs": "2.4.3",
"error-value": "0.3.0",
"express": "4.18.2",
"js-yaml": "4.1.0",
"matchacho": "0.3.5",
"msgpackr": "1.10.1",
Expand All @@ -49,7 +47,6 @@
"@toa.io/extensions.storages": "1.0.0-alpha.3",
"@types/bcryptjs": "2.4.3",
"@types/cors": "2.8.13",
"@types/express": "4.17.17",
"@types/negotiator": "0.6.1"
},
"gitHead": "24d68d70a56717f2f4441cc9884a60f9fee0863e"
Expand Down
6 changes: 3 additions & 3 deletions extensions/exposition/source/Directive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import assert from 'node:assert'
import { generate } from 'randomstring'
import { DirectivesFactory, type Family } from './Directive'
import { type syntax } from './RTD'
import { type IncomingMessage } from './HTTP'
import { type Remotes } from './Remotes'
import type { Context } from './HTTP'

const families: Array<jest.MockedObjectDeep<Family>> = [
{
Expand Down Expand Up @@ -76,7 +76,7 @@ it('should apply directive', async () => {
}

const directives = factory.create([declaration])
const request = generate() as unknown as IncomingMessage
const request = generate() as unknown as Context
const directive = families[0].create.mock.results[0].value

await directives.preflight(request, [])
Expand All @@ -89,7 +89,7 @@ it('should apply directive', async () => {

it('should apply mandatory families', async () => {
const directives = factory.create([])
const request = generate() as unknown as IncomingMessage
const request = generate() as unknown as Context

await directives.preflight(request, [])

Expand Down
22 changes: 11 additions & 11 deletions extensions/exposition/source/Directive.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IncomingMessage, OutgoingMessage } from './HTTP'
import type { Context, OutgoingMessage } from './HTTP'
import type { Remotes } from './Remotes'
import type { Output } from './io'
import type * as RTD from './RTD'
Expand All @@ -10,15 +10,15 @@ export class Directives implements RTD.Directives<Directives> {
this.sets = sets
}

public async preflight (request: IncomingMessage, parameters: RTD.Parameter[]): Promise<Output> {
public async preflight (context: Context, parameters: RTD.Parameter[]): Promise<Output> {
for (const set of this.sets) {
if (set.family.preflight === undefined)
continue

const output = await set.family.preflight(set.directives, request, parameters)
const output = await set.family.preflight(set.directives, context, parameters)

if (output !== null) {
await this.settle(request, output)
await this.settle(context, output)

return output
}
Expand All @@ -27,10 +27,10 @@ export class Directives implements RTD.Directives<Directives> {
return null
}

public async settle (request: IncomingMessage, response: OutgoingMessage): Promise<void> {
public async settle (context: Context, response: OutgoingMessage): Promise<void> {
for (const set of this.sets)
if (set.family.settle !== undefined)
await set.family.settle(set.directives, request, response)
await set.family.settle(set.directives, context, response)
}

public merge (directives: Directives): void {
Expand All @@ -39,7 +39,7 @@ export class Directives implements RTD.Directives<Directives> {
}

export class DirectivesFactory implements RTD.DirectivesFactory<Directives> {
private readonly remtoes: Remotes
private readonly remotes: Remotes
private readonly families: Record<string, Family> = {}
private readonly mandatory: string[] = []

Expand All @@ -51,7 +51,7 @@ export class DirectivesFactory implements RTD.DirectivesFactory<Directives> {
this.mandatory.push(family.name)
}

this.remtoes = remotes
this.remotes = remotes
}

public create (declarations: RTD.syntax.Directive[]): Directives {
Expand All @@ -67,7 +67,7 @@ export class DirectivesFactory implements RTD.DirectivesFactory<Directives> {
if (family === undefined)
throw new Error(`Directive family '${declaration.family}' is not found.`)

const directive = family.create(declaration.name, declaration.value, this.remtoes)
const directive = family.create(declaration.name, declaration.value, this.remotes)

groups[family.name] ??= []
groups[family.name].push(directive)
Expand Down Expand Up @@ -109,11 +109,11 @@ export interface Family<TDirective = any, TExtension = any> {
create: (name: string, value: any, remotes: Remotes) => TDirective

preflight?: (directives: TDirective[],
request: IncomingMessage & TExtension,
request: Context & TExtension,
parameters: RTD.Parameter[]) => Output | Promise<Output>

settle?: (directives: TDirective[],
request: IncomingMessage & TExtension,
request: Context & TExtension,
response: OutgoingMessage) => void | Promise<void>
}

Expand Down
9 changes: 6 additions & 3 deletions extensions/exposition/source/Factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type Directives, DirectivesFactory } from './Directive'
import { Composition } from './Composition'
import * as root from './root'
import { Interception } from './Interception'
import type { Broadcast } from './Gateway'
import type { Connector, Locator, extensions } from '@toa.io/core'

export class Factory implements extensions.Factory {
Expand All @@ -19,15 +20,15 @@ export class Factory implements extensions.Factory {
}

public tenant (locator: Locator, node: syntax.Node): Connector {
const broadcast = this.boot.bindings.broadcast(CHANNEL, locator.id)
const broadcast: Broadcast = this.boot.bindings.broadcast(CHANNEL, locator.id)

return new Tenant(broadcast, locator, node)
}

public service (): Connector | null {
const debug = process.env.TOA_EXPOSITION_DEBUG === '1'
const trace = process.env.TOA_EXPOSITION_TRACE === '1'
const broadcast = this.boot.bindings.broadcast(CHANNEL)
const broadcast: Broadcast = this.boot.bindings.broadcast(CHANNEL)
const server = Server.create({ methods: syntax.verbs, debug, trace })
const remotes = new Remotes(this.boot)
const node = root.resolve()
Expand All @@ -37,10 +38,12 @@ export class Factory implements extensions.Factory {
const tree = new Tree<Endpoint, Directives>(node, methods, directives)

const composition = new Composition(this.boot)
const gateway = new Gateway(broadcast, server, tree, interception)
const gateway = new Gateway(broadcast, tree, interception)

gateway.depends(remotes)
gateway.depends(composition)

server.attach(gateway.process.bind(gateway))
server.depends(gateway)

return server
Expand Down
62 changes: 29 additions & 33 deletions extensions/exposition/source/Gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,79 +13,75 @@ export class Gateway extends Connector {
private readonly broadcast: Broadcast
private readonly tree: Tree<Endpoint, Directives>
private readonly interceptor: Interception
private readonly server: Connector

// eslint-disable-next-line max-params, max-len
public constructor (broadcast: Broadcast, server: http.Server, tree: Tree<Endpoint, Directives>, interception: Interception) {
// eslint-disable-next-line max-len
public constructor (broadcast: Broadcast, tree: Tree<Endpoint, Directives>, interception: Interception) {
super()

this.broadcast = broadcast
this.tree = tree
this.interceptor = interception
this.server = server

this.depends(broadcast)
// this.depends(server)

server.attach(this.process.bind(this))
}

protected override async open (): Promise<void> {
await this.discover()

console.info('Gateway has started and is awaiting resource branches.')
}

protected override dispose (): void {
console.info('Gateway is closed.')
}

private async process (request: http.IncomingMessage): Promise<http.OutgoingMessage> {
const interception = await request.timing.capture('gate:intercept',
this.interceptor.intercept(request))
public async process (context: http.Context): Promise<http.OutgoingMessage> {
const interception = await context.timing.capture('gate:intercept',
this.interceptor.intercept(context))

if (interception !== null)
return interception

const match = this.tree.match(request.path)
const match = this.tree.match(context.url.pathname)

if (match === null)
throw new http.NotFound()

const { node, parameters } = match

if (!(request.method in node.methods))
if (!(context.request.method in node.methods))
throw new http.MethodNotAllowed()

const method = node.methods[request.method]
const method = node.methods[context.request.method]

const interruption = await request.timing.capture('gate:preflight',
method.directives.preflight(request, parameters))
const interruption = await context.timing.capture('gate:preflight',
method.directives.preflight(context, parameters))

const response = interruption ??
await request.timing.capture('gate:call', this.call(method, request, parameters))
await context.timing.capture('gate:call', this.call(method, context, parameters))

await request.timing.capture('gate:settle', method.directives.settle(request, response))
await context.timing.capture('gate:settle', method.directives.settle(context, response))

return response
}

protected override async open (): Promise<void> {
await this.discover()

console.info('Gateway has started and is awaiting resource branches.')
}

protected override dispose (): void {
console.info('Gateway is closed.')
}

private async call
(method: Method<Endpoint, Directives>, request: http.IncomingMessage, parameters: Parameter[]):
(method: Method<Endpoint, Directives>, context: http.Context, parameters: Parameter[]):
Promise<http.OutgoingMessage> {
if (request.path[request.path.length - 1] !== '/')
if (context.url.pathname[context.url.pathname.length - 1] !== '/')
throw new http.NotFound('Trailing slash is required.')

if (request.encoder === null)
if (context.encoder === null)
throw new http.NotAcceptable()

if (method.endpoint === null)
throw new http.MethodNotAllowed()

const body = await request.parse()
const body = await context.body()
const query = Object.fromEntries(context.url.searchParams)

const reply = await method.endpoint
.call(body, request.query, parameters)
.call(body, query, parameters)
.catch(rethrow) as Maybe<unknown>

if (reply instanceof Error)
Expand All @@ -111,4 +107,4 @@ export class Gateway extends Connector {
}
}

type Broadcast = bindings.Broadcast<Label>
export type Broadcast = bindings.Broadcast<Label>
60 changes: 60 additions & 0 deletions extensions/exposition/source/HTTP/Context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Negotiator from 'negotiator'
import { Timing } from './Timing'
import { type Format, formats, types } from './formats'
import { read } from './messages'
import type { OutgoingMessage } from './messages'
import type * as http from 'node:http'

export class Context {
public readonly request: IncomingMessage
tinovyatkin marked this conversation as resolved.
Show resolved Hide resolved
public readonly url: URL
public readonly subtype: string | null = null
public readonly encoder: Format | null = null
public readonly timing: Timing

public readonly pipelines: {
body: Array<(input: unknown) => unknown>
response: Array<(output: OutgoingMessage) => void>
} = { body: [], response: [] }

public constructor (request: IncomingMessage, trace = false) {
this.request = request

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- checked by Server
this.url = new URL(request.url, `https://${request.headers.host}`)
this.timing = new Timing(trace)

if (this.request.headers.accept !== undefined) {
const match = SUBTYPE.exec(this.request.headers.accept)

if (match !== null) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- checked by regex
const { type, subtype, suffix } = match.groups!

this.request.headers.accept = `${type}/${suffix}`
this.subtype = subtype
}
}

const negotiator = new Negotiator(this.request)
const mediaType = negotiator.mediaType(types)

if (mediaType !== undefined)
this.encoder = formats[mediaType]
}

public async body<T> (): Promise<T> {
const value = await read(this)

return this.pipelines.body.length === 0
? value
: this.pipelines.body.reduce((value, transform) => transform(value), value)
}
}

export interface IncomingMessage extends http.IncomingMessage {
url: string
method: string
}

const SUBTYPE = /^(?<type>\w{1,32})\/(vnd\.toa\.(?<subtype>\S{1,32})\+)(?<suffix>\S{1,32})$/
Loading
Loading