This commit is contained in:
Maik Riechert 2020-10-22 10:20:46 +02:00 коммит произвёл GitHub
Родитель 71a59975ab
Коммит 783c203345
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
26 изменённых файлов: 2165 добавлений и 26 удалений

8
samples/apps/forum/.gitignore поставляемый Normal file
Просмотреть файл

@ -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
1 Topic Opinion Type
2 Contoso, Ltd - Country of Risk string
3 Woodgrove Bank - Country of Risk string
4 Proseware - Country of Risk string
5 Fabrikam - Country of Risk string
6 Contoso, Ltd - 1Y CDS Spread number
7 Woodgrove Bank - 1Y CDS Spread number
8 Proseware - 1Y CDS Spread number
9 Fabrikam - 1Y CDS Spread number
10 Contoso, Ltd - 3Y CDS Spread number
11 Woodgrove Bank - 3Y CDS Spread number
12 Proseware - 3Y CDS Spread number
13 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,