diff --git a/samples/apps/forum/.gitignore b/samples/apps/forum/.gitignore new file mode 100644 index 000000000..96f688f7f --- /dev/null +++ b/samples/apps/forum/.gitignore @@ -0,0 +1,8 @@ +package-lock.json +node_modules/ +dist/ +build/ +.venv_ccf_sandbox/ +.workspace_ccf/ +*_opinions.csv +*.jwt \ No newline at end of file diff --git a/samples/apps/forum/README.md b/samples/apps/forum/README.md new file mode 100644 index 000000000..c211f7efd --- /dev/null +++ b/samples/apps/forum/README.md @@ -0,0 +1,29 @@ +# Confidential Forum sample app + +NOTE: This sample is a work-in-progress. + +Install dependencies: +```sh +npm install +``` + +Start the sandbox: +```sh +npm start +``` + +Open your browser at https://127.0.0.1:8000/app/site + +Create polls by copy-pasting test/demo/polls.csv. + +Generate opinions, user identities and submit: +```sh +python test/demo/generate-opinions.py test/demo/polls.csv 9 +npm run ts test/demo/generate-jwts.ts . 9 +npm run ts test/demo/submit-opinions.ts . +``` + +Run tests: +```sh +npm test +``` diff --git a/samples/apps/forum/app.tmpl.json b/samples/apps/forum/app.tmpl.json new file mode 100644 index 000000000..ecab1fdac --- /dev/null +++ b/samples/apps/forum/app.tmpl.json @@ -0,0 +1,106 @@ +{ + "endpoints": { + "/polls": { + "post": { + "js_module": "build/PollControllerProxy.js", + "js_function": "createPoll", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": false + }, + "put": { + "js_module": "build/PollControllerProxy.js", + "js_function": "submitOpinion", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": false + }, + "get": { + "js_module": "build/PollControllerProxy.js", + "js_function": "getPoll", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": true + } + }, + "/polls/all": { + "post": { + "js_module": "build/PollControllerProxy.js", + "js_function": "createPolls", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": false + }, + "put": { + "js_module": "build/PollControllerProxy.js", + "js_function": "submitOpinions", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": false + }, + "get": { + "js_module": "build/PollControllerProxy.js", + "js_function": "getPolls", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": true + } + }, + "/site": { + "get": { + "js_module": "build/SiteControllerProxy.js", + "js_function": "getStartPage", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": true + } + }, + "/site/polls/create": { + "get": { + "js_module": "build/SiteControllerProxy.js", + "js_function": "getPollsCreatePage", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": true + } + }, + "/site/opinions/submit": { + "get": { + "js_module": "build/SiteControllerProxy.js", + "js_function": "getOpinionsSubmitPage", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": true + } + }, + "/site/view": { + "get": { + "js_module": "build/SiteControllerProxy.js", + "js_function": "getViewPage", + "forwarding_required": "always", + "execute_locally": false, + "require_client_identity": false, + "require_client_signature": false, + "readonly": true + } + } + } +} \ No newline at end of file diff --git a/samples/apps/forum/package.json b/samples/apps/forum/package.json new file mode 100644 index 000000000..82e79d1f5 --- /dev/null +++ b/samples/apps/forum/package.json @@ -0,0 +1,45 @@ +{ + "private": true, + "scripts": { + "build": "del-cli -f dist/ build/ && tsoa spec-and-routes && node tsoa-support/postprocess.js && rollup --config && del-cli dist/src/build/endpoints.js", + "start": "npm run build && npm run ts test/start.ts", + "test": "npm run build && ts-mocha -r esm -p tsconfig.json test/**/*.test.ts", + "ts": "node --no-warnings --experimental-specifier-resolution=node --loader ts-node/esm" + }, + "type": "module", + "dependencies": { + "@tsoa/runtime": "^3.3.0", + "jwt-decode": "^3.0.0", + "lodash-es": "^4.17.15", + "mathjs": "^7.5.1" + }, + "devDependencies": { + "@apidevtools/swagger-parser": "^10.0.2", + "@rollup/plugin-commonjs": "^14.0.0", + "@rollup/plugin-node-resolve": "^8.4.0", + "@rollup/plugin-typescript": "^5.0.2", + "@tsoa/cli": "^3.3.0", + "@types/bent": "^7.3.1", + "@types/chai": "^4.2.13", + "@types/jsonwebtoken": "^8.5.0", + "@types/jwt-decode": "^2.2.1", + "@types/lodash-es": "^4.17.3", + "@types/mathjs": "^6.0.5", + "@types/mocha": "^8.0.3", + "@types/node-fetch": "^2.5.7", + "bent": "^7.3.12", + "chai": "^4.2.0", + "csv-parse": "^4.12.0", + "del-cli": "^3.0.1", + "esm": "^3.2.25", + "glob": "^7.1.6", + "http-server": "^0.12.3", + "jsonwebtoken": "^8.5.1", + "mocha": "^8.1.3", + "rollup": "^2.23.0", + "ts-mocha": "^7.0.0", + "ts-node": "^9.0.0", + "tslib": "^2.0.1", + "typescript": "^4.0.2" + } +} diff --git a/samples/apps/forum/rollup.config.js b/samples/apps/forum/rollup.config.js new file mode 100644 index 000000000..08ee8c457 --- /dev/null +++ b/samples/apps/forum/rollup.config.js @@ -0,0 +1,17 @@ +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; + +export default { + input: 'build/endpoints.ts', + output: { + dir: 'dist/src', + format: 'es', + preserveModules: true + }, + plugins: [ + nodeResolve(), + typescript(), + commonjs(), + ] +}; \ No newline at end of file diff --git a/samples/apps/forum/src/controllers/poll.ts b/samples/apps/forum/src/controllers/poll.ts new file mode 100644 index 000000000..3a42cf8bd --- /dev/null +++ b/samples/apps/forum/src/controllers/poll.ts @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import { + Body, + Path, + Header, + Query, + SuccessResponse, + Response, + Controller, + Get, + Post, + Put, + Route, +} from "@tsoa/runtime"; + +import * as _ from 'lodash-es' +import * as math from 'mathjs' + +import { ValidateErrorResponse, ValidateErrorStatus } from "../error_handler" +import { parseAuthToken } from "../util" +import * as ccf from "../types/ccf" + +export const MINIMUM_OPINION_THRESHOLD = 10 + +interface ErrorResponse { + message: string +} + +interface CreatePollRequest { + type: "string" | "number" +} + +interface CreatePollsRequest { + polls: { [topic: string]: CreatePollRequest } +} + +type Opinion = string | number + +interface SubmitOpinionRequest { + opinion: Opinion +} + +interface SubmitOpinionsRequest { + opinions: { [topic: string]: SubmitOpinionRequest } +} + +interface StringPollResponse { + type: "string" + statistics?: { + counts: { [ opinion: string]: number} + } + opinion?: string +} + +interface NumericPollResponse { + type: "number" + statistics?: { + mean: number + std: number + } + opinion?: number +} + +type GetPollResponse = StringPollResponse | NumericPollResponse + +interface GetPollsResponse { + polls: { [topic: string]: GetPollResponse } +} + +// Export REST API request/response types for unit tests +export { + CreatePollRequest, SubmitOpinionRequest, SubmitOpinionsRequest, + GetPollResponse, StringPollResponse, NumericPollResponse +} + +namespace kv { + type User = string + + interface PollBase { + creator: string + type: string + opinions: Record + } + + interface StringPoll extends PollBase { + type: "string" + } + + interface NumericPoll extends PollBase { + type: "number" + } + + export type Poll = StringPoll | NumericPoll +} + +// GET /polls/{topic} return poll +// POST /polls/{topic} create poll +// PUT /polls/{topic} submit opinion +// GET /polls return all polls +// POST /polls create multiple polls +// PUT /polls submit opinions for multiple polls + + +@Route("polls") +export class PollController extends Controller { + + private kvPolls = new ccf.TypedKVMap(ccf.kv.polls, ccf.string, ccf.json()) + private kvTopics = new ccf.TypedKVMap(ccf.kv.topics, ccf.string, ccf.json()) + private kvTopicsKey = 'all' + + @SuccessResponse(201, "Poll has been successfully created") + @Response(403, "Poll has not been created because a poll with the same topic exists already") + @Response(ValidateErrorStatus, "Schema validation error") + @Post() + public createPoll( + @Query() topic: string, + @Body() body: CreatePollRequest, + @Header() authorization: string, + ): void { + const user = parseAuthToken(authorization) + + if (this.kvPolls.has(topic)) { + this.setStatus(403) + return { message: "Poll with given topic exists already" } as any + } + this.kvPolls.set(topic, { + creator: user, + type: body.type, + opinions: {} + }) + const topics = this._getTopics() + topics.push(topic) + this.kvTopics.set(this.kvTopicsKey, topics) + this.setStatus(201) + } + + @SuccessResponse(201, "Polls have been successfully created") + @Response(403, "Polls were not created because a poll with the same topic exists already") + @Response(ValidateErrorStatus, "Schema validation error") + @Post('all') + public createPolls( + @Body() body: CreatePollsRequest, + @Header() authorization: string, + ): void { + const user = parseAuthToken(authorization) + + for (let [topic, poll] of Object.entries(body.polls)) { + if (this.kvPolls.has(topic)) { + this.setStatus(403) + return { message: `Poll with topic '${topic}' exists already` } as any + } + this.kvPolls.set(topic, { + creator: user, + type: poll.type, + opinions: {} + }) + const topics = this._getTopics() + topics.push(topic) + this.kvTopics.set(this.kvTopicsKey, topics) + } + this.setStatus(201) + } + + @SuccessResponse(204, "Opinion has been successfully recorded") + @Response(400, "Opinion was not recorded because the opinion data type does not match the poll type") + @Response(404, "Opinion was not recorded because no poll with the given topic exists") + @Response(ValidateErrorStatus, "Schema validation error") + @Put() + public submitOpinion( + @Query() topic: string, + @Body() body: SubmitOpinionRequest, + @Header() authorization: string, + ): void { + const user = parseAuthToken(authorization) + + try { + var poll = this.kvPolls.get(topic) + } catch (e) { + this.setStatus(404) + return { message: "Poll does not exist" } as any + } + if (typeof body.opinion !== poll.type) { + this.setStatus(400) + return { message: "Poll has a different opinion type" } as any + } + poll.opinions[user] = body.opinion + this.kvPolls.set(topic, poll) + this.setStatus(204) + } + + @SuccessResponse(204, "Opinions have been successfully recorded") + @Response(400, "Opinions were not recorded because either an opinion data type did not match the poll type or a poll with the given topic was not found") + @Response(ValidateErrorStatus, "Schema validation error") + @Put('all') + public submitOpinions( + @Body() body: SubmitOpinionsRequest, + @Header() authorization: string, + ): void { + const user = parseAuthToken(authorization) + + for (const [topic, opinion] of Object.entries(body.opinions)) { + try { + var poll = this.kvPolls.get(topic) + } catch (e) { + this.setStatus(400) + return { message: `Poll with topic '${topic}' does not exist` } as any + } + if (typeof opinion.opinion !== poll.type) { + this.setStatus(400) + return { message: `Poll with topic '${topic}' has a different opinion type` } as any + } + poll.opinions[user] = opinion.opinion + this.kvPolls.set(topic, poll) + } + + this.setStatus(204) + } + + @SuccessResponse(200, "Poll data") + @Response(404, "Poll data could not be returned because no poll with the given topic exists") + @Response(ValidateErrorStatus, "Schema validation error") + @Get() + public getPoll( + @Query() topic: string, + @Header() authorization: string, + ): GetPollResponse { + const user = parseAuthToken(authorization) + + if (!this.kvPolls.has(topic)){ + this.setStatus(404) + return { message: "Poll does not exist" } as any + } + + this.setStatus(200) + return this._getPoll(user, topic) + } + + @SuccessResponse(200, "Poll data") + @Response(ValidateErrorStatus, "Schema validation error") + @Get('all') + public getPolls( + @Header() authorization: string, + ): GetPollsResponse { + const user = parseAuthToken(authorization) + + let response: GetPollsResponse = { polls: {} } + + for (const topic of this._getTopics()) { + response.polls[topic] = this._getPoll(user, topic) + } + + this.setStatus(200) + return response + } + + _getTopics(): string[] { + try { + return this.kvTopics.get(this.kvTopicsKey) + } catch (e) { + return [] + } + } + + _getPoll(user: string, topic: string): GetPollResponse { + try { + var poll = this.kvPolls.get(topic) + } catch (e) { + throw new Error(`Poll with topic '${topic}' does not exist`) + } + + const opinionCountAboveThreshold = Object.keys(poll.opinions).length >= MINIMUM_OPINION_THRESHOLD + + const response: GetPollResponse = { type: poll.type } + // TODO can repetition be avoided while maintaining type checking? + if (poll.type == "string") { + response.opinion = poll.opinions[user] + if (opinionCountAboveThreshold) { + const opinions = Object.values(poll.opinions) + response.statistics = { + counts: _.countBy(opinions) + } + } + } else if (poll.type == "number") { + response.opinion = poll.opinions[user] + if (opinionCountAboveThreshold) { + const opinions = Object.values(poll.opinions) + response.statistics = { + mean: math.mean(opinions), + std: math.std(opinions) + } + } + } else { + throw new Error('unknown poll type') + } + return response + } +} \ No newline at end of file diff --git a/samples/apps/forum/src/controllers/site.ts b/samples/apps/forum/src/controllers/site.ts new file mode 100644 index 000000000..3bf6d7a95 --- /dev/null +++ b/samples/apps/forum/src/controllers/site.ts @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import { + Hidden, + Controller, + Get, + Route, +} from "@tsoa/runtime"; + +import { ValidateErrorResponse, ValidateErrorStatus } from "../error_handler" +import { parseAuthToken } from "../util" + +const HEADER_HTML = ` + + + + + + Forum + + + + + + + + + + + + + + + + +` + +const FOOTER_HTML = ` + + +` + +const START_HTML = ` +${HEADER_HTML} + +
+ +
+

Confidential Forum

+

Blabla
Blablabla.

+
+ +
+ +${FOOTER_HTML} +` + +const CREATE_POLLS_HTML = ` +${HEADER_HTML} + +
+ + +
+ + +
+ + + +${FOOTER_HTML} +` + +const SUBMIT_OPINIONS_HTML = ` +${HEADER_HTML} + +
+ + +
+ + +
+ + +${FOOTER_HTML} +` + +const VIEW_HTML = ` +${HEADER_HTML} + + +
+
+
+ + +${FOOTER_HTML} +` + +const HTML_CONTENT_TYPE = 'text/html' + +@Hidden() +@Route("site") +export class SiteController extends Controller { + + @Get() + public getStartPage(): any { + this.setHeader('content-type', HTML_CONTENT_TYPE) + return START_HTML + } + + @Get('polls/create') + public getPollsCreatePage(): any { + this.setHeader('content-type', HTML_CONTENT_TYPE) + return CREATE_POLLS_HTML + } + + @Get('opinions/submit') + public getOpinionsSubmitPage(): any { + this.setHeader('content-type', HTML_CONTENT_TYPE) + return SUBMIT_OPINIONS_HTML + } + + @Get('view') + public getViewPage(): any { + this.setHeader('content-type', HTML_CONTENT_TYPE) + return VIEW_HTML + } +} \ No newline at end of file diff --git a/samples/apps/forum/src/error_handler.ts b/samples/apps/forum/src/error_handler.ts new file mode 100644 index 000000000..4e19814a9 --- /dev/null +++ b/samples/apps/forum/src/error_handler.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import { ValidateError, FieldErrors } from "@tsoa/runtime"; +import * as ccf from './types/ccf' + +// The global error handler. Gets called for: +// - Request schema validation errors +// - Uncaught exceptions in controller actions + +// See https://tsoa-community.github.io/docs/error-handling.html#setting-up-error-handling +// The code that imports and calls this handler is in tsoa-support/routes.ts.tmpl. + +export interface ValidateErrorResponse { + message: "Validation failed" + details: FieldErrors +} + +export const ValidateErrorStatus = 422 + +export function errorHandler(err: unknown, req: ccf.Request): ccf.Response { + if (err instanceof ValidateError) { + return { + body: { + message: "Validation failed", + details: err.fields + }, + statusCode: ValidateErrorStatus + } + } + // Let CCF turn all other errors into 500. + throw err; +} diff --git a/samples/apps/forum/src/types/ccf.ts b/samples/apps/forum/src/types/ccf.ts new file mode 100644 index 000000000..785538ba7 --- /dev/null +++ b/samples/apps/forum/src/types/ccf.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +// Types/objects exposed from C++: + +// This should eventually cover all JSON-compatible values. +// There are attempts at https://github.com/microsoft/TypeScript/issues/1897 +// to create such a type but it needs further refinement. +type JsonCompatible = any + +export interface Body> { + text: () => string + json: () => T + arrayBuffer: () => ArrayBuffer +} + +export interface Request = any> { + headers: { [key: string]: string; } + params: { [key: string]: string; } + query: string + body: Body +} + +type ResponseBodyType = string | ArrayBuffer | JsonCompatible + +export interface Response = any> { + statusCode?: number + headers?: { [key: string]: string; } + body?: T +} + +export type EndpointFn = any, B extends ResponseBodyType = any> = + (request: Request) => Response + +export interface KVMap { + has: (key: ArrayBuffer) => boolean + get: (key: ArrayBuffer) => ArrayBuffer + set: (key: ArrayBuffer, value: ArrayBuffer) => void + delete: (key: ArrayBuffer) => void +} + +export type KVMaps = { [key: string]: KVMap; }; + +interface WrapAlgoBase { + name: string +} + +export interface RsaOaepParams extends WrapAlgoBase { + // name == 'RSA-OAEP' + label?: ArrayBuffer +} + +export type WrapAlgo = RsaOaepParams + +export interface CCF { + strToBuf(v: string): ArrayBuffer + bufToStr(v: ArrayBuffer): string + jsonCompatibleToBuf>(v: T): ArrayBuffer + bufToJsonCompatible>(v: ArrayBuffer): T + generateAesKey(size: number): ArrayBuffer + wrapKey(key: ArrayBuffer, wrappingKey: ArrayBuffer, wrapAlgo: WrapAlgo): ArrayBuffer + + kv: KVMaps +} + +export const ccf = globalThis.ccf as CCF + +// Additional functionality on top of C++: + +// Optional, so that this module can be (indirectly) imported outside CCF. +export const kv = ccf ? ccf.kv : undefined + +export interface DataConverter { + encode(val: T): ArrayBuffer + decode(arr: ArrayBuffer): T +} + +export class BoolConverter implements DataConverter { + encode(val: boolean): ArrayBuffer { + const buf = new ArrayBuffer(1); + new DataView(buf).setUint8(0, val ? 1 : 0); + return buf; + } + decode(buf: ArrayBuffer): boolean { + return new DataView(buf).getUint8(0) === 1 ? true : false; + } +} +export class Int8Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(1); + new DataView(buf).setInt8(0, val); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getInt8(0); + } +} +export class Uint8Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(2); + new DataView(buf).setUint8(0, val); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getUint8(0); + } +} +export class Int16Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(2); + new DataView(buf).setInt16(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getInt16(0, true); + } +} +export class Uint16Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(2); + new DataView(buf).setUint16(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getUint16(0, true); + } +} +export class Int32Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(4); + new DataView(buf).setInt32(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getInt32(0, true); + } +} +export class Uint32Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(4); + new DataView(buf).setUint32(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getUint32(0, true); + } +} +export class Int64Converter implements DataConverter { + encode(val: bigint): ArrayBuffer { + const buf = new ArrayBuffer(8); + new DataView(buf).setBigInt64(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): bigint { + return new DataView(buf).getBigInt64(0, true); + } +} +export class Uint64Converter implements DataConverter { + encode(val: bigint): ArrayBuffer { + const buf = new ArrayBuffer(8); + new DataView(buf).setBigUint64(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): bigint { + return new DataView(buf).getBigUint64(0, true); + } +} +export class Float32Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(4); + new DataView(buf).setFloat32(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getFloat32(0, true); + } +} +export class Float64Converter implements DataConverter { + encode(val: number): ArrayBuffer { + const buf = new ArrayBuffer(8); + new DataView(buf).setFloat64(0, val, true); + return buf; + } + decode(buf: ArrayBuffer): number { + return new DataView(buf).getFloat64(0, true); + } +} +export class StringConverter implements DataConverter { + encode(val: string): ArrayBuffer { + return ccf.strToBuf(val); + } + decode(buf: ArrayBuffer): string { + return ccf.bufToStr(buf); + } +} +export class JSONConverter> implements DataConverter { + encode(val: T): ArrayBuffer { + return ccf.jsonCompatibleToBuf(val); + } + decode(buf: ArrayBuffer): T { + return ccf.bufToJsonCompatible(buf); + } +} + +type TypedArray = ArrayBufferView + +interface TypedArrayConstructor { + new (buffer: ArrayBuffer, byteOffset?: number, length?: number): T +} + +export class TypedArrayConverter implements DataConverter { + constructor(private clazz: TypedArrayConstructor) { + } + encode(val: T): ArrayBuffer { + return val.buffer.slice(val.byteOffset, val.byteOffset + val.byteLength); + } + decode(buf: ArrayBuffer): T { + return new this.clazz(buf); + } +} +export class IdentityConverter implements DataConverter { + encode(val: ArrayBuffer): ArrayBuffer { + return val; + } + decode(buf: ArrayBuffer): ArrayBuffer { + return buf; + } +} + +export const bool = new BoolConverter(); +export const int8 = new Int8Converter(); +export const uint8 = new Uint8Converter(); +export const int16 = new Int16Converter(); +export const uint16 = new Uint16Converter(); +export const int32 = new Int32Converter(); +export const uint32 = new Uint32Converter(); +export const int64 = new Int64Converter(); +export const uint64 = new Uint64Converter(); +export const float32 = new Float32Converter(); +export const float64 = new Float64Converter(); +export const string = new StringConverter(); +export const json = >() => new JSONConverter(); +export const typedArray = (clazz: TypedArrayConstructor) => new TypedArrayConverter(clazz); +export const arrayBuffer = new IdentityConverter(); + +export class TypedKVMap { + constructor( + private kv: KVMap, + private kt: DataConverter, + private vt: DataConverter) { + } + has(key: K): boolean { + return this.kv.has(this.kt.encode(key)); + } + get(key: K): V { + return this.vt.decode(this.kv.get(this.kt.encode(key))); + } + set(key: K, value: V): void { + this.kv.set(this.kt.encode(key), this.vt.encode(value)); + } + delete(key: K): void { + this.kv.delete(this.kt.encode(key)); + } +} diff --git a/samples/apps/forum/src/util.ts b/samples/apps/forum/src/util.ts new file mode 100644 index 000000000..a12e419af --- /dev/null +++ b/samples/apps/forum/src/util.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import jwt_decode from 'jwt-decode' + +export function parseAuthToken(authHeader: string): string { + const parts = authHeader.split(' ', 2) + if (parts.length !== 2 || parts[0] !== 'Bearer') { + throw new Error('unexpected authorization type') + } + const token = parts[1] + const jwt = jwt_decode(token) as any + const user = jwt.sub + if (!user) { + throw new Error('invalid jwt, "sub" claim not found') + } + return user +} diff --git a/samples/apps/forum/test/demo/generate-jwts.ts b/samples/apps/forum/test/demo/generate-jwts.ts new file mode 100644 index 000000000..f8e64cfe1 --- /dev/null +++ b/samples/apps/forum/test/demo/generate-jwts.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import * as fs from 'fs' +import * as path from 'path' +import jwt from 'jsonwebtoken' + +const hmac_secret = 'secret' + +function main() { + const args = process.argv.slice(2) + if (args.length !== 2) { + console.error('Usage: npm run ts generate-jwts.ts folder count') + process.exit(1) + } + const folder = args[0] + const count = parseInt(args[1]) + console.log(`Generating ${count} JWTs in ${folder}`) + for (let i=0; i < count; i++) { + const payload = { + sub: 'user' + i + } + const token = jwt.sign(payload, hmac_secret) + const jwtPath = path.join(folder, 'user' + i + '.jwt') + console.log(`Writing ${jwtPath}`) + fs.writeFileSync(jwtPath, token) + } +} + +main() diff --git a/samples/apps/forum/test/demo/generate-opinions.py b/samples/apps/forum/test/demo/generate-opinions.py new file mode 100644 index 000000000..32fc8c46a --- /dev/null +++ b/samples/apps/forum/test/demo/generate-opinions.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +import sys +import csv +import random + + +def country(topic): + """ + Return a fictional country code, consensus + in most cases, except for Contoso. + """ + if topic.startswith("Contoso"): + return random.choice(("RED", "BLU")) + else: + return "RED" + + +def spread(topic): + """ + Return a fictional spread in bps, tight triangular + distribution in most cases, except for Fabrikam where + the spreads are more scattered, higher, and with a longer tail. + """ + if topic.startswith("Fabrikam"): + if " 1Y CDS Spread" in topic: + return random.triangular(140, 280, 180) + elif " 3Y CDS Spread" in topic: + return random.triangular(200, 400, 300) + else: + assert False + else: + if " 1Y CDS Spread" in topic: + return random.triangular(140, 150) + elif " 3Y CDS Spread" in topic: + return random.triangular(150, 160) + else: + assert False + + +def main(polls_path, user_count): + entries = [] + + with open(polls_path, "r") as pp: + polls = csv.DictReader(pp) + entries = [poll for poll in polls] + + for user in range(user_count): + with open(f"user{user}_opinions.csv", "w") as uf: + header = ["Topic", "Opinion"] + writer = csv.DictWriter(uf, header) + writer.writeheader() + for entry in entries: + + def push(opinion): + writer.writerow({header[0]: entry["Topic"], header[1]: opinion}) + + if entry["Opinion Type"] == "string": + push(country(entry["Topic"])) + elif entry["Opinion Type"] == "number": + push(spread(entry["Topic"])) + else: + assert False + + +if __name__ == "__main__": + main(sys.argv[1], int(sys.argv[2])) diff --git a/samples/apps/forum/test/demo/polls.csv b/samples/apps/forum/test/demo/polls.csv new file mode 100644 index 000000000..10f2586c2 --- /dev/null +++ b/samples/apps/forum/test/demo/polls.csv @@ -0,0 +1,16 @@ +"Topic","Opinion Type" + +"Contoso, Ltd - Country of Risk",string +"Woodgrove Bank - Country of Risk",string +"Proseware - Country of Risk",string +"Fabrikam - Country of Risk",string + +"Contoso, Ltd - 1Y CDS Spread",number +"Woodgrove Bank - 1Y CDS Spread",number +"Proseware - 1Y CDS Spread",number +"Fabrikam - 1Y CDS Spread",number + +"Contoso, Ltd - 3Y CDS Spread",number +"Woodgrove Bank - 3Y CDS Spread",number +"Proseware - 3Y CDS Spread",number +"Fabrikam - 3Y CDS Spread",number \ No newline at end of file diff --git a/samples/apps/forum/test/demo/submit-opinions.ts b/samples/apps/forum/test/demo/submit-opinions.ts new file mode 100644 index 000000000..d003b030b --- /dev/null +++ b/samples/apps/forum/test/demo/submit-opinions.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import * as fs from 'fs' +import * as path from 'path' +import glob from 'glob' +import bent from 'bent' +import csvparse from 'csv-parse/lib/sync' +import { NODE_ADDR } from '../util' +import { SubmitOpinionsRequest } from '../../src/controllers/poll' + +const ENDPOINT_URL = `${NODE_ADDR}/app/polls` + +function getAuth(jwt: string) { + // See src/util.ts. + return { + 'authorization': `Bearer ${jwt}'` + } +} + +interface CSVRow { + Topic: string + Opinion: string +} + +async function main() { + const args = process.argv.slice(2) + if (args.length !== 1) { + console.error('Usage: npm run ts submit-opinions.ts folder') + process.exit(1) + } + const folder = args[0] + const csvPaths = glob.sync(folder + '/*_opinions.csv') + for (const csvPath of csvPaths) { + const user = path.basename(csvPath).replace('_opinions.csv', '') + const jwtPath = path.join(folder, user + '.jwt') + const jwt = fs.readFileSync(jwtPath, 'utf8') + const csv = fs.readFileSync(csvPath) + const rows: CSVRow[] = csvparse(csv, {columns: true, skipEmptyLines: true}) + + const req: SubmitOpinionsRequest = { opinions: {} } + for (const row of rows) { + req.opinions[row.Topic] = { opinion: isNumber(row.Opinion) ? parseFloat(row.Opinion) : row.Opinion } + } + console.log('Submitting opinions for user ' + user) + try { + await bent('PUT', 204)(`${ENDPOINT_URL}/all`, req, getAuth(jwt)) + } catch (e) { + console.error('Error: ' + await e.text()) + process.exit(1) + } + } +} + +function isNumber(s: string) { + return !Number.isNaN(Number(s)) +} + +main() diff --git a/samples/apps/forum/test/poll.test.ts b/samples/apps/forum/test/poll.test.ts new file mode 100644 index 000000000..8a928eedd --- /dev/null +++ b/samples/apps/forum/test/poll.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import { assert } from 'chai' +import bent from 'bent' +import jwt from 'jsonwebtoken' +import { NODE_ADDR, setupMochaCCFSandbox } from './util' +import { + CreatePollRequest, SubmitOpinionRequest, + NumericPollResponse, StringPollResponse, + MINIMUM_OPINION_THRESHOLD, + GetPollResponse +} from '../src/controllers/poll' + +const APP_BUNDLE_DIR = 'dist' +const ENDPOINT_URL = `${NODE_ADDR}/app/polls` + +// Note: In order to use a single CCF instance (and hence keep tests fast), +// each test uses a different poll topic. + +function getAuth(userId: number) { + const payload = { + sub: 'user' + userId + } + const secret = 'dummy' + const token = jwt.sign(payload, secret) + return { + 'authorization': `Bearer ${token}'` + } +} + +describe('/polls', function () { + setupMochaCCFSandbox(APP_BUNDLE_DIR) + + describe('POST /{topic}', function () { + it('creates numeric polls', async function () { + const topic = 'post-a' + const body: CreatePollRequest = { + type: "number" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, body, getAuth(1)) + }) + it('creates string polls', async function () { + const topic = 'post-b' + const body: CreatePollRequest = { + type: "string" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, body, getAuth(1)) + }) + it('rejects creating polls with an existing topic', async function () { + const topic = 'post-c' + const body: CreatePollRequest = { + type: "string" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, body, getAuth(1)) + await bent('POST', 403)(`${ENDPOINT_URL}?topic=${topic}`, body, getAuth(1)) + }) + it('rejects creating polls without authorization', async function () { + const topic = 'post-d' + const body: CreatePollRequest = { + type: "string" + } + // 422 = validation error, because the header is missing, should be 401 + await bent('POST', 422)(`${ENDPOINT_URL}?topic=${topic}`, body) + }) + }) + describe('PUT /{topic}', function () { + it('stores opinions to a topic', async function () { + const topic = 'put-a' + const pollBody: CreatePollRequest = { + type: "number" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, pollBody, getAuth(1)) + + const opinionBody: SubmitOpinionRequest = { + opinion: 1.2 + } + await bent('PUT', 204)(`${ENDPOINT_URL}?topic=${topic}`, opinionBody, getAuth(1)) + }) + it('rejects opinions with mismatching data type', async function () { + const topic = 'put-b' + const pollBody: CreatePollRequest = { + type: "number" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, pollBody, getAuth(1)) + + const opinionBody: SubmitOpinionRequest = { + opinion: "foo" + } + await bent('PUT', 400)(`${ENDPOINT_URL}?topic=${topic}`, opinionBody, getAuth(1)) + }) + it('rejects opinions for unknown topics', async function () { + const body: SubmitOpinionRequest = { + opinion: 1.2 + } + await bent('PUT', 404)(`${ENDPOINT_URL}?topic=non-existing`, body, getAuth(1)) + }) + it('rejects opinions without authorization', async function () { + const topic = 'put-c' + const pollBody: CreatePollRequest = { + type: "number" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, pollBody, getAuth(1)) + + const opinionBody: SubmitOpinionRequest = { + opinion: 1.2 + } + // 422 = validation error, because the header is missing, should be 401 + await bent('PUT', 422)(`${ENDPOINT_URL}?topic=${topic}`, opinionBody) + }) + }) + describe('GET /{topic}', function () { + it('returns aggregated numeric poll opinions', async function () { + const topic = 'get-a' + const pollBody: CreatePollRequest = { + type: "number" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, pollBody, getAuth(1)) + + let opinions = [1.5, 0.9, 1.2, 1.5, 0.9, 1.2, 1.5, 0.9, 1.2, 1.5] + for (let i = 0; i < opinions.length; i++) { + const opinionBody: SubmitOpinionRequest = { + opinion: opinions[i] + } + await bent('PUT', 204)(`${ENDPOINT_URL}?topic=${topic}`, opinionBody, getAuth(i)) + } + + let aggregated: NumericPollResponse = + await bent('GET', 'json', 200)(`${ENDPOINT_URL}?topic=${topic}`, null, getAuth(1)) + assert.equal(aggregated.statistics.mean, opinions.reduce((a, b) => a + b, 0) / opinions.length) + }) + it('returns aggregated string poll opinions', async function () { + const topic = 'get-b' + const pollBody: CreatePollRequest = { + type: "string" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, pollBody, getAuth(1)) + + let opinions = ["foo", "foo", "bar", "foo", "foo", "bar", "foo", "foo", "bar", "foo"] + for (let i = 0; i < opinions.length; i++) { + const opinionBody: SubmitOpinionRequest = { + opinion: opinions[i] + } + await bent('PUT', 204)(`${ENDPOINT_URL}?topic=${topic}`, opinionBody, getAuth(i)) + } + + let aggregated: StringPollResponse = + await bent('GET', 'json', 200)(`${ENDPOINT_URL}?topic=${topic}`, null, getAuth(1)) + assert.equal(aggregated.statistics.counts["foo"], 7) + assert.equal(aggregated.statistics.counts["bar"], 3) + }) + it('rejects returning aggregated opinions below the required opinion count threshold', async function () { + const topic = 'get-c' + const pollBody: CreatePollRequest = { + type: "number" + } + await bent('POST', 201)(`${ENDPOINT_URL}?topic=${topic}`, pollBody, getAuth(1)) + + for (let i = 0; i < MINIMUM_OPINION_THRESHOLD - 1; i++) { + const opinionBody: SubmitOpinionRequest = { + opinion: 1.0 + } + await bent('PUT', 204)(`${ENDPOINT_URL}?topic=${topic}`, opinionBody, getAuth(i)) + } + + const poll: GetPollResponse = await bent('GET', 'json', 200)(`${ENDPOINT_URL}?topic=${topic}`, null, getAuth(1)) + assert.notExists(poll.statistics) + }) + it('rejects returning aggregated opinions for unknown topics', async function () { + await bent('GET', 404)(`${ENDPOINT_URL}?topic=non-existing`, null, getAuth(1)) + }) + }) +}) diff --git a/samples/apps/forum/test/start.ts b/samples/apps/forum/test/start.ts new file mode 100644 index 000000000..00ffac250 --- /dev/null +++ b/samples/apps/forum/test/start.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import { spawnSync } from 'child_process' +import * as util from './util' + +const app_bundle_dir = 'dist' + +function main() { + const {command, args} = util.getCCFSandboxCmdAndArgs(app_bundle_dir) + spawnSync(command, args, { stdio: 'inherit' }) +} + +main() diff --git a/samples/apps/forum/test/util.ts b/samples/apps/forum/test/util.ts new file mode 100644 index 000000000..f23494511 --- /dev/null +++ b/samples/apps/forum/test/util.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +import { ChildProcess, spawn } from 'child_process' +import * as path from 'path' + +// accept self-signed certs +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" + +const NODE_HOST = '127.0.0.1:8000' +export const NODE_ADDR = 'https://' + NODE_HOST + +export function getCCFSandboxCmdAndArgs(app_bundle_dir: string) { + const CCF_SANDBOX_ARGS = [ + '--node', NODE_HOST, + '--js-app-bundle', app_bundle_dir, + '--workspace', '.workspace_ccf' + ] + if (process.env.VERBOSE == '1') { + CCF_SANDBOX_ARGS.push('--verbose') + } + + // This logic allows to run tests easily from a CCF install or the CCF repository. + // Most of this will disappear once CCF's build folder uses the same layout as an install. + let CCF_SANDBOX_SCRIPT: string + const CCF_REPO_ROOT = path.join('..', '..', '..') + const CCF_BINARY_DIR = process.env.CCF_BINARY_DIR || path.join(CCF_REPO_ROOT, 'build') + if (path.basename(CCF_BINARY_DIR) === 'bin') { + // ccf install tree + CCF_SANDBOX_SCRIPT = path.join(CCF_BINARY_DIR, 'sandbox.sh') + } else { + // ccf repo tree + CCF_SANDBOX_SCRIPT = path.join(CCF_REPO_ROOT, 'tests', 'sandbox', 'sandbox.sh') + CCF_SANDBOX_ARGS.push('--binary-dir', CCF_BINARY_DIR) + } + return { + command: CCF_SANDBOX_SCRIPT, + args: CCF_SANDBOX_ARGS + } +} + +export function setupMochaCCFSandbox(app_bundle_dir: string) { + const {command, args} = getCCFSandboxCmdAndArgs(app_bundle_dir) + + let sandboxProcess: ChildProcess + before(function () { + this.timeout(20000) // first time takes longer due to venv install + return new Promise((resolve, reject) => { + sandboxProcess = spawn(command, args, { + stdio: ['pipe', 'pipe', 'inherit'], + timeout: 30000 // sandbox startup + max test duration + }) + sandboxProcess.on('exit', reject) + sandboxProcess.stdout.on('data', data => { + const msg = data.toString() + console.log(msg) + if (msg.includes('Started CCF network')) { + sandboxProcess.off('exit', reject) + resolve() + } + }) + }) + }) + + after(function () { + this.timeout(5000) + return new Promise((resolve, reject) => { + sandboxProcess.on('exit', () => { + resolve() + }) + sandboxProcess.kill("SIGINT") + }) + }) +} \ No newline at end of file diff --git a/samples/apps/forum/tsconfig.json b/samples/apps/forum/tsconfig.json new file mode 100644 index 000000000..56c0b2ef6 --- /dev/null +++ b/samples/apps/forum/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ES2020"], + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "noImplicitAny": false, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": false + }, + "include": [ + "src/**/*", + "test/**/*", + "build/**/*" + ] +} \ No newline at end of file diff --git a/samples/apps/forum/tsoa-support/entry.ts b/samples/apps/forum/tsoa-support/entry.ts new file mode 100644 index 000000000..679df3d6c --- /dev/null +++ b/samples/apps/forum/tsoa-support/entry.ts @@ -0,0 +1 @@ +// tsoa's CLI requires an entry script but it is not actually used. \ No newline at end of file diff --git a/samples/apps/forum/tsoa-support/postprocess.js b/samples/apps/forum/tsoa-support/postprocess.js new file mode 100644 index 000000000..e4880d442 --- /dev/null +++ b/samples/apps/forum/tsoa-support/postprocess.js @@ -0,0 +1,185 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import SwaggerParser from "@apidevtools/swagger-parser"; + +// endpoint metadata defaults when first added to endpoints.json +const metadataDefaults = (readonly) => ({ + forwarding_required: 'always', + execute_locally: false, + require_client_identity: true, + require_client_signature: false, + readonly: readonly +}); + +const distDir = './dist'; + +// generated by tsoa from code +const openapiPath = './build/swagger.json'; + +// generated by tsoa using routes.ts.tmpl +const routesPath = './build/routes.ts'; + +// special markers in generated routes.ts +const markerHelpersStart = '// CCF:HELPERS-START' +const markerHelpersEnd = '// CCF:HELPERS-END' +const markerMetadataStart = '// CCF:METADATA-START' +const markerMetadataEnd = '// CCF:METADATA-END' +const markerControllerStart = '// CCF:CONTROLLER-START=' +const markerControllerEnd = '// CCF:CONTROLLER-END' + +// files generated by this script +const helpersPath = './build/helpers.ts'; +const controllerProxyPath = './build/{}Proxy.ts'; +const endpointsPath = './build/endpoints.ts'; +const metadataPath = './app.tmpl.json'; +const finalMetadataPath = `${distDir}/app.json`; + +// read generated routes.ts +const file = fs.readFileSync(routesPath, 'utf8'); + +// copy helpers script part to separate file +const helpersStartIdx = file.indexOf(markerHelpersStart); +const helpersEndIdx = file.indexOf(markerHelpersEnd, helpersStartIdx); +const helpersCode = file.substring( + helpersStartIdx + markerHelpersStart.length, + helpersEndIdx); +fs.writeFileSync(helpersPath, helpersCode); + +// copy controller script parts to separate files +let controllerStartIdx = 0; +const proxyPaths = []; +while (true) { + controllerStartIdx = file.indexOf(markerControllerStart, controllerStartIdx); + if (controllerStartIdx === -1) + break; + const controllerEndIdx = file.indexOf(markerControllerEnd, controllerStartIdx); + if (controllerEndIdx === -1) + throw new Error(`'${markerControllerEnd}' not found`); + const controllerNameEndIdx = file.indexOf('\n', controllerStartIdx); + const controllerName = file.substring(controllerStartIdx + markerControllerStart.length, + controllerNameEndIdx).trim(); + const controllerCode = file.substring(controllerNameEndIdx, controllerEndIdx); + const proxyPath = controllerProxyPath.replace('{}', controllerName); + proxyPaths.push(proxyPath); + fs.writeFileSync(proxyPath, controllerCode); + controllerStartIdx = controllerEndIdx; +} + +// create build/endpoints.ts (only needed for rollup as entry point) +let endpointsCode = ''; +for (const proxyPath of proxyPaths) { + const proxyName = path.basename(proxyPath, '.ts'); + endpointsCode += `export * as ${proxyName} from './${proxyName}';\n`; +} +fs.writeFileSync(endpointsPath, endpointsCode); + +// Create/update app.json which maps +// URL + METHOD -> module name + function. +const metadataStartIdx = file.indexOf(markerMetadataStart); +const metadataEndIdx = file.indexOf(markerMetadataEnd, metadataStartIdx); +const metadataJson = file.substring( + metadataStartIdx + markerMetadataStart.length, + metadataEndIdx); +let newMetadata = JSON.parse(metadataJson); + +// tsoa groups routes by controllers and actions. +// For app.json, we need to group by url and method instead. +let tmp = {endpoints: {}} +for (let controller of newMetadata.controllers) { + for (let action of controller.actions) { + // transform /a/:b/:c to /a/{b}/{c} + let url = action.full_path.replace(/:([^\/]+)/g, (_, name) => `{${name}}`) + if (!tmp.endpoints[url]) { + tmp.endpoints[url] = {} + } + tmp.endpoints[url][action.method] = { + js_module: controller.js_module, + js_function: action.js_function + } + } +} +newMetadata = tmp + +let oldMetadata = {endpoints: {}}; +if (fs.existsSync(metadataPath)) { + oldMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); +} +const oldEndpoints = oldMetadata['endpoints']; +const newEndpoints = newMetadata['endpoints']; +for (const url in newEndpoints) { + for (const method in newEndpoints[url]) { + const readonly = method == 'get'; + Object.assign(newEndpoints[url][method], metadataDefaults(readonly)); + } +} +console.log(`Updating ${metadataPath} (if needed)`); +let wasUpdated = false; +for (const url in oldEndpoints) { + if (!(url in newEndpoints)) { + console.log(`Removed: ${url}`); + wasUpdated = true; + } +} +for (const url in newEndpoints) { + if (!(url in oldEndpoints)) { + console.log(`Added: ${url}`); + wasUpdated = true; + continue; + } + const oldMethods = oldEndpoints[url]; + const newMethods = newEndpoints[url]; + for (const method in oldMethods) { + if (!(method in newMethods)) { + console.log(`Removed: ${url} [${method}]`); + wasUpdated = true; + } + } + for (const method in newMethods) { + if (!(method in oldMethods)) { + console.log(`Added: ${url} [${method}]`); + wasUpdated = true; + continue; + } + // Copy from old but update module & function + const oldCfg = oldMethods[method]; + const newCfg = newMethods[method]; + if (oldCfg['js_module'] != newCfg['js_module'] || + oldCfg['js_function'] != newCfg['js_function']) { + oldCfg['js_module'] = newCfg['js_module']; + oldCfg['js_function'] = newCfg['js_function']; + console.log(`Updated: ${url} [${method}]`); + wasUpdated = true; + } else { + console.log(`Unchanged: ${url} [${method}]`); + } + newMethods[method] = oldCfg; + } +} +if (wasUpdated) { + fs.writeFileSync(metadataPath, JSON.stringify(newMetadata, null, 2)); +} + +// delete routes.ts since its content is now split into multiple files +fs.unlinkSync(routesPath); + +// create dist/endpoints.json which includes stand-alone OpenAPI Operation objects +// for each endpoint +SwaggerParser.dereference(openapiPath).then(openapi => { + for (const url in newEndpoints) { + const pathItem = openapi.paths[url] || {}; + for (const method in newEndpoints[url]) { + let operation = pathItem[method]; + if (!operation) { + console.log(`WARNING: ${url} [${method}] not found in OpenAPI document`); + operation = null + } + if (!newEndpoints[url][method]['openapi']) + newEndpoints[url][method]['openapi'] = operation; + } + } + fs.mkdirSync(distDir, { recursive: true }); + fs.writeFileSync(finalMetadataPath, JSON.stringify(newMetadata, null, 2)); +}).catch(e => { + console.error(`${e}`) + process.exit(1) +}) diff --git a/samples/apps/forum/tsoa-support/routes.ts.tmpl b/samples/apps/forum/tsoa-support/routes.ts.tmpl new file mode 100644 index 000000000..c582013e8 --- /dev/null +++ b/samples/apps/forum/tsoa-support/routes.ts.tmpl @@ -0,0 +1,193 @@ +// A tsoa routes template for CCF. Needs to be post-processed by postprocess.js. + +// CCF:HELPERS-START +/* tslint:disable */ +/* eslint-disable */ +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { Controller, ValidationService, FieldErrors, ValidateError, TsoaRoute, HttpStatusCodeLiteral, TsoaResponse } from '@tsoa/runtime'; + +{{#if authenticationModule}} +UNSUPPORTED: authenticationModule +{{/if}} +{{#if iocModule}} +UNSUPPORTED: iocModule +{{/if}} +{{#if useSecurity}} +UNSUPPORTED: useSecurity +{{/if}} + +const models: TsoaRoute.Models = { + {{#each models}} + "{{@key}}": { + {{#if enums}} + "dataType": "refEnum", + "enums": {{{json enums}}}, + {{/if}} + {{#if properties}} + "dataType": "refObject", + "properties": { + {{#each properties}} + "{{@key}}": {{{json this}}}, + {{/each}} + }, + "additionalProperties": {{{json additionalProperties}}}, + {{/if}} + {{#if type}} + "dataType": "refAlias", + "type": {{{json type}}}, + {{/if}} + }, + {{/each}} +}; +const validationService = new ValidationService(models); + +export function parseQuery(queryString: string) { + const query = {}; + const pairs = queryString.split('&'); + for (const pair of pairs) { + const [name, value] = pair.split('='); + query[decodeURIComponent(name)] = decodeURIComponent(value || ''); + } + return query; +} + +function isController(object: any): object is Controller { + return 'getHeaders' in object && 'getStatus' in object && 'setStatus' in object; +} + +export function promiseHandler(controllerObj: any, data: any, response: any) { + // Note: This function is supposed to handle Promise responses but + // for now this has been stripped out to stay compatible with CCF. + + let statusCode; + let headers; + if (isController(controllerObj)) { + headers = controllerObj.getHeaders(); + statusCode = controllerObj.getStatus(); + } + + returnHandler(response, statusCode, data, headers) +} + +function returnHandler(response: any, statusCode?: number, data?: any, headers: any = {}) { + Object.keys(headers).forEach((name: string) => { + response.headers[name] = headers[name]; + }); + if (data || data === false) { // === false allows boolean result + response.statusCode = statusCode || 200; + response.body = data; + } else { + response.statusCode = statusCode || 204; + } +} + +function responder(response: any): TsoaResponse { + return function(status, data, headers) { + returnHandler(response, status, data, headers); + }; +}; + +export function getValidatedArgs(args: any, request: any, response: any): any[] { + const fieldErrors: FieldErrors = {}; + const values = Object.keys(args).map((key) => { + const name = args[key].name; + switch (args[key].in) { + case 'request': + return request; + case 'query': + return validationService.ValidateParam(args[key], request.query[name], name, fieldErrors, undefined, {{{json minimalSwaggerConfig}}}); + case 'path': + return validationService.ValidateParam(args[key], request.params[name], name, fieldErrors, undefined, {{{json minimalSwaggerConfig}}}); + case 'header': + return validationService.ValidateParam(args[key], request.headers[name], name, fieldErrors, undefined, {{{json minimalSwaggerConfig}}}); + case 'body': + return validationService.ValidateParam(args[key], request.body.json(), name, fieldErrors, undefined, {{{json minimalSwaggerConfig}}}); + case 'body-prop': + return validationService.ValidateParam(args[key], request.body.json()[name], name, fieldErrors, 'body.', {{{json minimalSwaggerConfig}}}); + case 'res': + return responder(response); + } + }); + + if (Object.keys(fieldErrors).length > 0) { + throw new ValidateError(fieldErrors, ''); + } + return values; +} + +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +// CCF:HELPERS-END + +// CCF:METADATA-START +{ + "controllers": [ + {{#each controllers}} + { + "path": "{{path}}", + "js_module": "build/{{name}}Proxy.js", + "actions": [ + {{#each actions}} + { + "full_path": "{{fullPath}}", + "method": "{{method}}", + "js_function": "{{name}}" + }{{#unless @last}},{{/unless}} + {{/each}} + ] + }{{#unless @last}},{{/unless}} + {{/each}} + ] +} +// CCF:METADATA-END + +{{#each controllers}} + +// CCF:CONTROLLER-START={{name}} +/* tslint:disable */ +/* eslint-disable */ +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + +import { parseQuery, getValidatedArgs, promiseHandler } from './helpers' +import { errorHandler } from '../src/error_handler' +import { {{name}} } from '{{modulePath}}'; + +// {{modulePath}} {{name}} {{path}} + +{{#each actions}} +// {{method}} {{fullPath}} +export function {{name}}(request) { + const args = { + {{#each parameters}} + {{@key}}: {{{json this}}}, + {{/each}} + }; + + const request_ = { + body: request.body, + headers: request.headers, + query: parseQuery(request.query), + params: request.params + } + const response = { + body: undefined, + statusCode: undefined, + headers: {} + } + + try { + let validatedArgs = getValidatedArgs(args, request_, response); + + const controller = new {{../name}}(); + + const data = controller.{{name}}.apply(controller, validatedArgs as any); + promiseHandler(controller, data, response); + return response; + } catch (err) { + return errorHandler(err, request); + } +} +{{/each}} + +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +// CCF:CONTROLLER-END +{{/each}} diff --git a/samples/apps/forum/tsoa.json b/samples/apps/forum/tsoa.json new file mode 100644 index 000000000..fe1e7f3a9 --- /dev/null +++ b/samples/apps/forum/tsoa.json @@ -0,0 +1,16 @@ +{ + "entryFile": "tsoa-support/entry.ts", + "noImplicitAdditionalProperties": "throw-on-extras", + "controllerPathGlobs": [ + "src/controllers/*.ts" + ], + "spec": { + "outputDirectory": "build", + "specVersion": 3, + "basePath": "/app" + }, + "routes": { + "routesDir": "build", + "middlewareTemplate": "tsoa-support/routes.ts.tmpl" + } +} \ No newline at end of file diff --git a/tests/npm-app/src/types/ccf.ts b/tests/npm-app/src/types/ccf.ts index e3dcc1d2f..785538ba7 100644 --- a/tests/npm-app/src/types/ccf.ts +++ b/tests/npm-app/src/types/ccf.ts @@ -1,16 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + // Types/objects exposed from C++: -// adapted from https://github.com/microsoft/TypeScript/issues/1897#issuecomment-648485567 -type Json = void | null | boolean | number | string | Json[] | { [prop: string]: Json } -type JsonCompatible = { - [P in keyof T]: T[P] extends Json - ? T[P] - : Pick extends Required> - ? never - : T[P] extends (() => any) | undefined - ? never - : JsonCompatible -} +// This should eventually cover all JSON-compatible values. +// There are attempts at https://github.com/microsoft/TypeScript/issues/1897 +// to create such a type but it needs further refinement. +type JsonCompatible = any export interface Body> { text: () => string @@ -70,7 +66,9 @@ export interface CCF { export const ccf = globalThis.ccf as CCF // Additional functionality on top of C++: -export const kv = ccf.kv + +// Optional, so that this module can be (indirectly) imported outside CCF. +export const kv = ccf ? ccf.kv : undefined export interface DataConverter { encode(val: T): ArrayBuffer diff --git a/tests/npm-app/tsconfig.json b/tests/npm-app/tsconfig.json index 8ba669961..5b5026a47 100644 --- a/tests/npm-app/tsconfig.json +++ b/tests/npm-app/tsconfig.json @@ -1,6 +1,9 @@ { "compilerOptions": { - "module": "ES2015", + "lib": ["ES2020"], + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", "noImplicitAny": false, "removeComments": true, "preserveConstEnums": true, diff --git a/tests/npm-tsoa-app/src/types/ccf.ts b/tests/npm-tsoa-app/src/types/ccf.ts index e3dcc1d2f..785538ba7 100644 --- a/tests/npm-tsoa-app/src/types/ccf.ts +++ b/tests/npm-tsoa-app/src/types/ccf.ts @@ -1,16 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + // Types/objects exposed from C++: -// adapted from https://github.com/microsoft/TypeScript/issues/1897#issuecomment-648485567 -type Json = void | null | boolean | number | string | Json[] | { [prop: string]: Json } -type JsonCompatible = { - [P in keyof T]: T[P] extends Json - ? T[P] - : Pick extends Required> - ? never - : T[P] extends (() => any) | undefined - ? never - : JsonCompatible -} +// This should eventually cover all JSON-compatible values. +// There are attempts at https://github.com/microsoft/TypeScript/issues/1897 +// to create such a type but it needs further refinement. +type JsonCompatible = any export interface Body> { text: () => string @@ -70,7 +66,9 @@ export interface CCF { export const ccf = globalThis.ccf as CCF // Additional functionality on top of C++: -export const kv = ccf.kv + +// Optional, so that this module can be (indirectly) imported outside CCF. +export const kv = ccf ? ccf.kv : undefined export interface DataConverter { encode(val: T): ArrayBuffer diff --git a/tests/npm-tsoa-app/tsconfig.json b/tests/npm-tsoa-app/tsconfig.json index 515528cff..334d4ddf5 100644 --- a/tests/npm-tsoa-app/tsconfig.json +++ b/tests/npm-tsoa-app/tsconfig.json @@ -1,6 +1,9 @@ { "compilerOptions": { - "module": "ES2015", + "lib": ["ES2020"], + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", "experimentalDecorators": true, "noImplicitAny": false, "removeComments": true,