зеркало из https://github.com/microsoft/CCF.git
Forum sample (#1787)
This commit is contained in:
Родитель
71a59975ab
Коммит
783c203345
|
@ -0,0 +1,8 @@
|
|||
package-lock.json
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.venv_ccf_sandbox/
|
||||
.workspace_ccf/
|
||||
*_opinions.csv
|
||||
*.jwt
|
|
@ -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
|
||||
```
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
]
|
||||
};
|
|
@ -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<T> {
|
||||
creator: string
|
||||
type: string
|
||||
opinions: Record<User, T>
|
||||
}
|
||||
|
||||
interface StringPoll extends PollBase<string> {
|
||||
type: "string"
|
||||
}
|
||||
|
||||
interface NumericPoll extends PollBase<number> {
|
||||
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<kv.Poll>())
|
||||
private kvTopics = new ccf.TypedKVMap(ccf.kv.topics, ccf.string, ccf.json<string[]>())
|
||||
private kvTopicsKey = 'all'
|
||||
|
||||
@SuccessResponse(201, "Poll has been successfully created")
|
||||
@Response<ErrorResponse>(403, "Poll has not been created because a poll with the same topic exists already")
|
||||
@Response<ValidateErrorResponse>(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<ErrorResponse>(403, "Polls were not created because a poll with the same topic exists already")
|
||||
@Response<ValidateErrorResponse>(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<ErrorResponse>(400, "Opinion was not recorded because the opinion data type does not match the poll type")
|
||||
@Response<ErrorResponse>(404, "Opinion was not recorded because no poll with the given topic exists")
|
||||
@Response<ValidateErrorResponse>(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<ErrorResponse>(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<ValidateErrorResponse>(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<ErrorResponse>(404, "Poll data could not be returned because no poll with the given topic exists")
|
||||
@Response<ValidateErrorResponse>(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<ValidateErrorResponse>(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
|
||||
}
|
||||
}
|
|
@ -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 = `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Forum</title>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
|
||||
|
||||
<style>
|
||||
body {
|
||||
padding-top: 5rem;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.container {
|
||||
max-width: 1250px;
|
||||
}
|
||||
}
|
||||
.start-info {
|
||||
padding: 3rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script src="//code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/js-cookie@3.0.0-rc.1/dist/js.cookie.min.js"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/crypto-js/4.0.0/crypto-js.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/jstat@1.9.4/dist/jstat.min.js"></script>
|
||||
<script src="//cdn.plot.ly/plotly-1.57.0.min.js"></script>
|
||||
<script src="//unpkg.com/papaparse@5.3.0/papaparse.min.js"></script>
|
||||
<script>
|
||||
window.$ = document.querySelector.bind(document)
|
||||
|
||||
const apiUrl = window.location.origin + '/app/polls'
|
||||
const userCookieName = 'user'
|
||||
|
||||
function getRandomInt(min, max) {
|
||||
min = Math.ceil(min)
|
||||
max = Math.floor(max)
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
function base64url(source) {
|
||||
source = CryptoJS.enc.Utf8.parse(source)
|
||||
return CryptoJS.enc.Base64.stringify(source).replace(/=+$/, '').replace(/\\+/g, '-').replace(/\\//g, '_')
|
||||
}
|
||||
|
||||
function generateTestJWT(user) {
|
||||
const secret = 'foobar'
|
||||
const header = JSON.stringify({
|
||||
alg: "HS256",
|
||||
typ: "JWT"
|
||||
})
|
||||
const payload = JSON.stringify({
|
||||
sub: user
|
||||
})
|
||||
const unsignedToken = base64url(header) + "." + base64url(payload)
|
||||
const token = unsignedToken + "." + base64url(CryptoJS.HmacSHA256(unsignedToken, secret))
|
||||
return token
|
||||
}
|
||||
|
||||
user = Cookies.get(userCookieName)
|
||||
if (!user) {
|
||||
user = 'joe' + getRandomInt(0, 1000).toString()
|
||||
Cookies.set(userCookieName, user)
|
||||
}
|
||||
jwt = generateTestJWT(user)
|
||||
console.log('JWT:', jwt)
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
$('#user').innerHTML = user
|
||||
})
|
||||
|
||||
function parseCSV(csv) {
|
||||
return Papa.parse(csv, {header: true, skipEmptyLines: true}).data
|
||||
}
|
||||
|
||||
async function retrieve(url, method, body) {
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'authorization': 'Bearer ' + jwt,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
console.error(error)
|
||||
throw new Error(error.message)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function createPoll(topic, type) {
|
||||
const body = { type: type }
|
||||
await retrieve(apiUrl + '?topic=' + topic, 'POST', body)
|
||||
}
|
||||
|
||||
async function createPolls(polls) {
|
||||
const body = { polls: polls }
|
||||
await retrieve(apiUrl + '/all', 'POST', body)
|
||||
}
|
||||
|
||||
async function submitOpinion(topic, opinion) {
|
||||
const body = { opinion: opinion }
|
||||
await retrieve(apiUrl + '?topic=' + topic, 'PUT', body)
|
||||
}
|
||||
|
||||
async function submitOpinions(opinions) {
|
||||
const body = { opinions: opinions }
|
||||
await retrieve(apiUrl + '/all', 'PUT', body)
|
||||
}
|
||||
|
||||
async function getPoll(topic) {
|
||||
const response = await retrieve(apiUrl + '?topic=' + topic, 'GET')
|
||||
const poll = await response.json()
|
||||
return poll
|
||||
}
|
||||
|
||||
async function getPolls() {
|
||||
const response = await retrieve(apiUrl + '/all', 'GET')
|
||||
const polls = await response.json()
|
||||
return polls.polls
|
||||
}
|
||||
|
||||
function plotPoll(element, topic, data) {
|
||||
if (!data.statistics) {
|
||||
plotEmptyPoll(element, topic)
|
||||
} else if (data.type == 'string') {
|
||||
plotStringPoll(element, topic, data)
|
||||
} else {
|
||||
plotNumberPoll(element, topic, data)
|
||||
}
|
||||
}
|
||||
|
||||
const margin = {l: 30, r: 30, t: 50, b: 50}
|
||||
|
||||
function plotNumberPoll(element, topic, data) {
|
||||
const mean = data.statistics.mean
|
||||
const std = data.statistics.std
|
||||
const normal = jStat.normal(mean, std)
|
||||
const xs = []
|
||||
const ys = []
|
||||
for (let i = mean - std*2; i < mean + std*2; i += 0.01) {
|
||||
xs.push(i)
|
||||
ys.push(normal.pdf(i))
|
||||
}
|
||||
|
||||
const trace = {
|
||||
x: xs,
|
||||
y: ys,
|
||||
opacity: 0.5,
|
||||
line: {
|
||||
color: 'rgba(255, 0, 0)',
|
||||
width: 4
|
||||
},
|
||||
type: 'scatter'
|
||||
}
|
||||
|
||||
const shapes = [{
|
||||
type: 'line',
|
||||
yref: 'paper',
|
||||
x0: mean,
|
||||
y0: 0,
|
||||
x1: mean,
|
||||
y1: 1,
|
||||
line:{
|
||||
color: 'black',
|
||||
width: 3,
|
||||
}
|
||||
}, {
|
||||
type: 'line',
|
||||
yref: 'paper',
|
||||
x0: mean - std,
|
||||
y0: 0,
|
||||
x1: mean - std,
|
||||
y1: 1,
|
||||
line:{
|
||||
color: 'black',
|
||||
width: 2,
|
||||
}
|
||||
}, {
|
||||
type: 'line',
|
||||
yref: 'paper',
|
||||
x0: mean + std,
|
||||
y0: 0,
|
||||
x1: mean + std,
|
||||
y1: 1,
|
||||
line:{
|
||||
color: 'black',
|
||||
width: 2,
|
||||
}
|
||||
}]
|
||||
const xtickvals = [mean, mean - std, mean + std]
|
||||
|
||||
if (data.opinion) {
|
||||
shapes.push({
|
||||
type: 'line',
|
||||
yref: 'paper',
|
||||
x0: data.opinion,
|
||||
y0: 0,
|
||||
x1: data.opinion,
|
||||
y1: 1,
|
||||
line:{
|
||||
color: '#69c272',
|
||||
width: 2,
|
||||
}
|
||||
})
|
||||
xtickvals.push(data.opinion)
|
||||
}
|
||||
|
||||
Plotly.newPlot(element, [trace], {
|
||||
title: topic,
|
||||
shapes: shapes,
|
||||
xaxis: {
|
||||
zeroline: false,
|
||||
showgrid: false,
|
||||
tickvals: xtickvals
|
||||
},
|
||||
yaxis: {
|
||||
visible: false,
|
||||
zeroline: false,
|
||||
showgrid: false,
|
||||
},
|
||||
margin: margin
|
||||
}, {displayModeBar: false})
|
||||
}
|
||||
|
||||
function plotStringPoll(element, topic, data) {
|
||||
const strings = Object.keys(data.statistics.counts)
|
||||
const counts = Object.values(data.statistics.counts)
|
||||
const colors = strings.map(s => s == data.opinion ? '#69c272' : 'rgba(204,204,204,1)')
|
||||
const trace = {
|
||||
x: strings,
|
||||
y: counts,
|
||||
marker:{
|
||||
color: colors
|
||||
},
|
||||
type: 'bar'
|
||||
}
|
||||
Plotly.newPlot(element, [trace], {
|
||||
title: topic,
|
||||
margin: margin,
|
||||
yaxis: {
|
||||
showgrid: false,
|
||||
},
|
||||
}, {displayModeBar: false})
|
||||
}
|
||||
|
||||
function plotEmptyPoll(element, topic) {
|
||||
Plotly.newPlot(element, [], {
|
||||
title: topic,
|
||||
margin: margin,
|
||||
xaxis: {
|
||||
zeroline: false,
|
||||
showticklabels: false
|
||||
},
|
||||
yaxis: {
|
||||
zeroline: false,
|
||||
showticklabels: false
|
||||
},
|
||||
annotations: [{
|
||||
xref: 'paper',
|
||||
yref: 'paper',
|
||||
xanchor: 'center',
|
||||
yanchor: 'bottom',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
text: 'NOT ENOUGH DATA',
|
||||
showarrow: false
|
||||
}]
|
||||
}, {displayModeBar: false});
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
|
||||
<a class="navbar-brand" href="/app/site">Confidential Forum</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/app/site/polls/create">Create Polls</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/app/site/opinions/submit">Submit Opinions</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/app/site/view">View Statistics</a>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="navbar-text">
|
||||
User: <span id="user"></span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
`
|
||||
|
||||
const FOOTER_HTML = `
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const START_HTML = `
|
||||
${HEADER_HTML}
|
||||
|
||||
<main role="main" class="container">
|
||||
|
||||
<div class="start-info">
|
||||
<h1>Confidential Forum</h1>
|
||||
<p class="lead">Blabla<br>Blablabla.</p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
${FOOTER_HTML}
|
||||
`
|
||||
|
||||
const CREATE_POLLS_HTML = `
|
||||
${HEADER_HTML}
|
||||
|
||||
<main role="main" class="container">
|
||||
|
||||
<textarea id="input-polls" rows="10" cols="70">Topic,Opinion Type
|
||||
My Topic,string
|
||||
My other topic,number</textarea>
|
||||
<br />
|
||||
<button id="create-polls-btn" class="btn btn-primary">Create Polls</button>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
$('#create-polls-btn').addEventListener('click', async () => {
|
||||
const rows = parseCSV($('#input-polls').value)
|
||||
const polls = {}
|
||||
for (const row of rows) {
|
||||
polls[row['Topic']] = { type: row['Opinion Type'] }
|
||||
}
|
||||
try {
|
||||
await createPolls(polls)
|
||||
} catch (e) {
|
||||
window.alert(e)
|
||||
return
|
||||
}
|
||||
window.alert('Successfully created polls.')
|
||||
$('#input-polls').value = ''
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
${FOOTER_HTML}
|
||||
`
|
||||
|
||||
const SUBMIT_OPINIONS_HTML = `
|
||||
${HEADER_HTML}
|
||||
|
||||
<main role="main" class="container">
|
||||
|
||||
<textarea id="input-opinions" rows="10" cols="70">Topic,Opinion
|
||||
My Topic,abc
|
||||
My other topic,1.4</textarea>
|
||||
<br />
|
||||
<button id="submit-opinions-btn" class="btn btn-primary">Submit Opinions</button>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
$('#submit-opinions-btn').addEventListener('click', async () => {
|
||||
const rows = parseCSV($('#input-opinions').value)
|
||||
const opinions = {}
|
||||
for (let row of rows) {
|
||||
let opinion = row['Opinion']
|
||||
if (!Number.isNaN(Number(opinion))) {
|
||||
opinion = parseFloat(opinion)
|
||||
}
|
||||
opinions[row['Topic']] = { opinion: opinion }
|
||||
}
|
||||
try {
|
||||
await submitOpinions(opinions)
|
||||
} catch (e) {
|
||||
window.alert(e)
|
||||
return
|
||||
}
|
||||
window.alert('Successfully submitted opinions.')
|
||||
$('#input-opinions').value = ''
|
||||
})
|
||||
|
||||
</script>
|
||||
${FOOTER_HTML}
|
||||
`
|
||||
|
||||
const VIEW_HTML = `
|
||||
${HEADER_HTML}
|
||||
<style>
|
||||
.plot {
|
||||
width: 300px;
|
||||
height: 150px;
|
||||
float: left;
|
||||
}
|
||||
</style>
|
||||
|
||||
<main role="main" class="container">
|
||||
<div id="plots"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
async function main() {
|
||||
const polls = await getPolls()
|
||||
const topics = Object.keys(polls)
|
||||
|
||||
const plotsEl = $('#plots')
|
||||
plotsEl.innerHTML = topics.map((topic,i) => '<div class="plot" id="plot_' + i + '"></div>').join('')
|
||||
for (let [i, topic] of topics.entries()) {
|
||||
plotPoll('plot_' + i, topic, polls[topic])
|
||||
}
|
||||
}
|
||||
main()
|
||||
|
||||
</script>
|
||||
${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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<T> = any
|
||||
|
||||
export interface Body<T extends JsonCompatible<T>> {
|
||||
text: () => string
|
||||
json: () => T
|
||||
arrayBuffer: () => ArrayBuffer
|
||||
}
|
||||
|
||||
export interface Request<T extends JsonCompatible<T> = any> {
|
||||
headers: { [key: string]: string; }
|
||||
params: { [key: string]: string; }
|
||||
query: string
|
||||
body: Body<T>
|
||||
}
|
||||
|
||||
type ResponseBodyType<T> = string | ArrayBuffer | JsonCompatible<T>
|
||||
|
||||
export interface Response<T extends ResponseBodyType<T> = any> {
|
||||
statusCode?: number
|
||||
headers?: { [key: string]: string; }
|
||||
body?: T
|
||||
}
|
||||
|
||||
export type EndpointFn<A extends JsonCompatible<A> = any, B extends ResponseBodyType<B> = any> =
|
||||
(request: Request<A>) => Response<B>
|
||||
|
||||
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<T extends JsonCompatible<T>>(v: T): ArrayBuffer
|
||||
bufToJsonCompatible<T extends JsonCompatible<T>>(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<T> {
|
||||
encode(val: T): ArrayBuffer
|
||||
decode(arr: ArrayBuffer): T
|
||||
}
|
||||
|
||||
export class BoolConverter implements DataConverter<boolean> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<bigint> {
|
||||
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<bigint> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<string> {
|
||||
encode(val: string): ArrayBuffer {
|
||||
return ccf.strToBuf(val);
|
||||
}
|
||||
decode(buf: ArrayBuffer): string {
|
||||
return ccf.bufToStr(buf);
|
||||
}
|
||||
}
|
||||
export class JSONConverter<T extends JsonCompatible<T>> implements DataConverter<T> {
|
||||
encode(val: T): ArrayBuffer {
|
||||
return ccf.jsonCompatibleToBuf(val);
|
||||
}
|
||||
decode(buf: ArrayBuffer): T {
|
||||
return ccf.bufToJsonCompatible(buf);
|
||||
}
|
||||
}
|
||||
|
||||
type TypedArray = ArrayBufferView
|
||||
|
||||
interface TypedArrayConstructor<T extends TypedArray> {
|
||||
new (buffer: ArrayBuffer, byteOffset?: number, length?: number): T
|
||||
}
|
||||
|
||||
export class TypedArrayConverter<T extends TypedArray> implements DataConverter<T> {
|
||||
constructor(private clazz: TypedArrayConstructor<T>) {
|
||||
}
|
||||
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<ArrayBuffer> {
|
||||
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 = <T extends JsonCompatible<T>>() => new JSONConverter<T>();
|
||||
export const typedArray = <T extends TypedArray>(clazz: TypedArrayConstructor<T>) => new TypedArrayConverter(clazz);
|
||||
export const arrayBuffer = new IdentityConverter();
|
||||
|
||||
export class TypedKVMap<K, V> {
|
||||
constructor(
|
||||
private kv: KVMap,
|
||||
private kt: DataConverter<K>,
|
||||
private vt: DataConverter<V>) {
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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()
|
|
@ -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]))
|
|
@ -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
|
|
|
@ -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()
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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()
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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/**/*"
|
||||
]
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
// tsoa's CLI requires an entry script but it is not actually used.
|
|
@ -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)
|
||||
})
|
|
@ -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<HttpStatusCodeLiteral, unknown> {
|
||||
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}}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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<T> = {
|
||||
[P in keyof T]: T[P] extends Json
|
||||
? T[P]
|
||||
: Pick<T, P> extends Required<Pick<T, P>>
|
||||
? never
|
||||
: T[P] extends (() => any) | undefined
|
||||
? never
|
||||
: JsonCompatible<T[P]>
|
||||
}
|
||||
// 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<T> = any
|
||||
|
||||
export interface Body<T extends JsonCompatible<T>> {
|
||||
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<T> {
|
||||
encode(val: T): ArrayBuffer
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "ES2015",
|
||||
"lib": ["ES2020"],
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
"removeComments": true,
|
||||
"preserveConstEnums": true,
|
||||
|
|
|
@ -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<T> = {
|
||||
[P in keyof T]: T[P] extends Json
|
||||
? T[P]
|
||||
: Pick<T, P> extends Required<Pick<T, P>>
|
||||
? never
|
||||
: T[P] extends (() => any) | undefined
|
||||
? never
|
||||
: JsonCompatible<T[P]>
|
||||
}
|
||||
// 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<T> = any
|
||||
|
||||
export interface Body<T extends JsonCompatible<T>> {
|
||||
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<T> {
|
||||
encode(val: T): ArrayBuffer
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "ES2015",
|
||||
"lib": ["ES2020"],
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
"noImplicitAny": false,
|
||||
"removeComments": true,
|
||||
|
|
Загрузка…
Ссылка в новой задаче