├── LICENSE ├── client ├── .browserslistrc ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── nginx.conf ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ └── football.svg │ ├── components │ │ ├── groups │ │ │ ├── CreateGroup.vue │ │ │ ├── GroupList.vue │ │ │ └── GroupListItem.vue │ │ └── shared │ │ │ └── Loader.vue │ ├── data │ │ ├── auth │ │ │ ├── auth-endpoint.ts │ │ │ ├── auth-service.ts │ │ │ └── models │ │ │ │ └── auth-info-model.ts │ │ ├── base-service.ts │ │ └── groups │ │ │ ├── groups-endpoint.ts │ │ │ ├── groups-service.ts │ │ │ └── models │ │ │ ├── create-group-command-result.model.ts │ │ │ ├── create-group-command.model.ts │ │ │ ├── group-creator.model.ts │ │ │ ├── group-details.model.ts │ │ │ ├── group-summary.model.ts │ │ │ ├── index.ts │ │ │ ├── update-group-details-command-result.model.ts │ │ │ └── update-group-details-command.model.ts │ ├── main.ts │ ├── router.ts │ ├── shared │ │ └── loader.functions.ts │ ├── shims-tsx.d.ts │ ├── shims-vue.d.ts │ ├── store │ │ ├── index.ts │ │ ├── modules │ │ │ ├── auth │ │ │ │ ├── actions.ts │ │ │ │ ├── getters.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mutations.ts │ │ │ │ └── state.ts │ │ │ ├── groups │ │ │ │ ├── actions.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mutations.ts │ │ │ │ └── state.ts │ │ │ └── loader │ │ │ │ ├── index.ts │ │ │ │ ├── mutations.ts │ │ │ │ └── state.ts │ │ └── state.ts │ └── views │ │ ├── About.vue │ │ ├── GroupDetails.vue │ │ ├── Groups.vue │ │ └── Home.vue ├── tests │ └── unit │ │ └── example.spec.ts ├── tsconfig.json ├── tslint.json └── vue.config.js ├── server ├── .gitignore ├── CodingMilitia.PlayBall.WebFrontend.BackForFront.sln ├── Dockerfile ├── benchmarks │ ├── ApiRouting │ │ ├── Attempt01DictionaryPlusStringManipulation.cs │ │ ├── Attempt02ArrayIterationPlusPathBeginsWith.cs │ │ ├── Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation.cs │ │ ├── Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation.cs │ │ ├── Attempt0504WithAggressiveInlining.cs │ │ ├── IProxiedApiRouteEndpointLookup.cs │ │ ├── Program.cs │ │ ├── ProxiedApiRouteEndpointLookup.csproj │ │ └── readme.md │ └── CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.sln ├── src │ └── CodingMilitia.PlayBall.WebFrontend.BackForFront.Web │ │ ├── ApiRouting │ │ └── ProxiedApiRouteEndpointLookup.cs │ │ ├── AuthTokenHelpers │ │ ├── AccessTokenHttpMessageHandler.cs │ │ ├── CustomCookieAuthenticationEvents.cs │ │ ├── Extensions.cs │ │ ├── ITokenRefresher.cs │ │ ├── TokenRefreshResult.cs │ │ └── TokenRefresher.cs │ │ ├── CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.csproj │ │ ├── Configuration │ │ ├── ApiSettings.cs │ │ ├── AuthServiceSettings.cs │ │ ├── ConfigurationExtensions.cs │ │ └── DataProtectionSettings.cs │ │ ├── Features │ │ └── Auth │ │ │ ├── AuthController.cs │ │ │ └── AuthInfoModel.cs │ │ ├── Program.cs │ │ ├── Security │ │ ├── EnforceAuthenticatedUserMiddleware.cs │ │ └── ValidateAntiForgeryTokenMiddleware.cs │ │ ├── Startup.cs │ │ ├── appsettings.Development.json │ │ ├── appsettings.DockerDevelopment.json │ │ └── appsettings.json └── tests │ └── CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test │ ├── ApiRouting │ └── ProxiedApiRouteEndpointLookupTests.cs │ └── CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test.csproj └── watch.sh /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Coding Militia: ASP.NET Core - From 0 to overkill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules/ -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # builder 2 | FROM node:lts-alpine as builder 3 | WORKDIR /app 4 | 5 | ## Storing node modules on a separate layer will prevent unnecessary npm installs at each build 6 | COPY package*.json ./ 7 | RUN npm install 8 | 9 | COPY . . 10 | RUN npm run build 11 | 12 | # Build runtime image 13 | FROM nginx:alpine as runtime 14 | 15 | ## Copy our default nginx config 16 | COPY nginx.conf /etc/nginx/conf.d/default.conf 17 | 18 | ## Remove default nginx website 19 | RUN rm -rf /usr/share/nginx/html/* 20 | 21 | ## From ‘builder’ stage copy over the artifacts in dist folder to default nginx public folder 22 | COPY --from=builder /app/dist /usr/share/nginx/html 23 | 24 | # Sample build command 25 | # docker build -t codingmilitia/webfrontend/spa . -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | npm run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Run your unit tests 29 | ``` 30 | npm run test:unit 31 | ``` 32 | 33 | ### Customize configuration 34 | See [Configuration Reference](https://cli.vuejs.org/config/). 35 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | root /usr/share/nginx/html; 4 | 5 | location / { 6 | try_files $uri $uri/ /index.html; # fallback to index.html when a static alternative is not found 7 | } 8 | } -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "test:unit": "vue-cli-service test:unit" 10 | }, 11 | "dependencies": { 12 | "axios": "^0.18.1", 13 | "bulma": "^0.8.0", 14 | "vue": "^2.6.10", 15 | "vue-class-component": "^6.0.0", 16 | "vue-property-decorator": "^7.3.0", 17 | "vue-router": "^3.1.3", 18 | "vuex": "^3.1.2", 19 | "vuex-class": "^0.3.2" 20 | }, 21 | "devDependencies": { 22 | "@types/chai": "^4.2.6", 23 | "@types/mocha": "^5.2.7", 24 | "@vue/cli-plugin-typescript": "^3.12.1", 25 | "@vue/cli-plugin-unit-mocha": "^3.12.1", 26 | "@vue/cli-service": "^3.12.1", 27 | "@vue/test-utils": "^1.0.0-beta.30", 28 | "chai": "^4.1.2", 29 | "node-sass": "^4.13.0", 30 | "sass-loader": "^7.3.1", 31 | "typescript": "^3.7.3", 32 | "vue-template-compiler": "^2.6.10" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AspNetCoreFromZeroToOverkill/WebFrontend/156c6944ce50c127e3208972c56e1443b8ab4903/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | client 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 50 | 51 | 79 | -------------------------------------------------------------------------------- /client/src/assets/football.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/components/groups/CreateGroup.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /client/src/components/groups/GroupList.vue: -------------------------------------------------------------------------------- 1 | 17 | 58 | -------------------------------------------------------------------------------- /client/src/components/groups/GroupListItem.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/components/shared/Loader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /client/src/data/auth/auth-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { AuthInfoModel } from './models/auth-info-model'; 2 | 3 | export interface AuthEndpoint { 4 | getAuthInfo(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/data/auth/auth-service.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { AuthInfoModel } from './models/auth-info-model'; 3 | import { AuthEndpoint } from './auth-endpoint'; 4 | import { BaseService } from '../base-service'; 5 | 6 | // TODO: handle eventual errors 7 | 8 | export class AuthService extends BaseService implements AuthEndpoint { 9 | private readonly baseUrl: string = '/auth'; 10 | 11 | public async getAuthInfo(): Promise { 12 | try { 13 | const response = await axios.get(`${this.baseUrl}/info`); 14 | return response.data; 15 | } catch (error) { 16 | // if we get a 401, the user isn't logged in 17 | if (error.response.status === 401) { 18 | return null; 19 | } 20 | throw error; 21 | } 22 | 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/src/data/auth/models/auth-info-model.ts: -------------------------------------------------------------------------------- 1 | export interface AuthInfoModel { 2 | name: string; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/data/base-service.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios'; 2 | 3 | export class BaseService { 4 | // TODO: there's probably a better way to do this configuration 5 | protected getAxiosConfig(): AxiosRequestConfig { 6 | return { xsrfHeaderName: 'X-XSRF-TOKEN', xsrfCookieName: 'XSRF-TOKEN'}; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/data/groups/groups-endpoint.ts: -------------------------------------------------------------------------------- 1 | import { UpdateGroupDetailsCommandModel } from './models/update-group-details-command.model'; 2 | import { CreateGroupCommandModel } from './models/create-group-command.model'; 3 | import { CreateGroupCommandResultModel } from './models/create-group-command-result.model'; 4 | import { UpdateGroupDetailsCommandResultModel } from './models/update-group-details-command-result.model'; 5 | import { GroupDetailsModel } from './models/group-details.model'; 6 | import { GroupSummaryModel } from './models/group-summary.model'; 7 | 8 | export interface GroupsEndpoint { 9 | getAll(): Promise; 10 | getById(id: number): Promise; 11 | add(createGroupCommand: CreateGroupCommandModel): Promise; 12 | update(id: number, updateGroupCommand: UpdateGroupDetailsCommandModel) 13 | : Promise; 14 | remove(id: number): Promise; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/data/groups/groups-service.ts: -------------------------------------------------------------------------------- 1 | import { GroupsEndpoint } from './groups-endpoint'; 2 | import axios from 'axios'; 3 | import { UpdateGroupDetailsCommandModel } from './models/update-group-details-command.model'; 4 | import { BaseService } from '../base-service'; 5 | import { CreateGroupCommandModel } from './models/create-group-command.model'; 6 | import { UpdateGroupDetailsCommandResultModel } from './models/update-group-details-command-result.model'; 7 | import { CreateGroupCommandResultModel } from './models/create-group-command-result.model'; 8 | import { GroupSummaryModel } from './models/group-summary.model'; 9 | import { GroupDetailsModel } from './models/group-details.model'; 10 | 11 | // TODO: handle eventual errors 12 | 13 | export class GroupsService extends BaseService implements GroupsEndpoint { 14 | private readonly baseUrl: string = '/api/groups'; 15 | 16 | public async getAll(): Promise { 17 | const response = await axios.get(this.baseUrl); 18 | return response.data; 19 | } 20 | 21 | public async getById(id: number): Promise { 22 | const response = await axios.get(`${this.baseUrl}/${id}`); 23 | return response.data; 24 | } 25 | 26 | public async add(createGroupCommand: CreateGroupCommandModel): Promise { 27 | const response = await axios.post(this.baseUrl, createGroupCommand, this.getAxiosConfig()); 28 | return response.data; 29 | } 30 | 31 | public async update(id: number, updateGroupCommand: UpdateGroupDetailsCommandModel) 32 | : Promise { 33 | const response = await axios.put(`${this.baseUrl}/${id}`, updateGroupCommand, this.getAxiosConfig()); 34 | return response.data; 35 | } 36 | 37 | public async remove(id: number): Promise { 38 | await axios.delete(`${this.baseUrl}/${id}`, this.getAxiosConfig()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/data/groups/models/create-group-command-result.model.ts: -------------------------------------------------------------------------------- 1 | export interface CreateGroupCommandResultModel { 2 | id: number; 3 | name: string; 4 | rowVersion: string; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/data/groups/models/create-group-command.model.ts: -------------------------------------------------------------------------------- 1 | export interface CreateGroupCommandModel { 2 | name: string; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/data/groups/models/group-creator.model.ts: -------------------------------------------------------------------------------- 1 | export interface GroupCreatorModel { 2 | id: string; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/data/groups/models/group-details.model.ts: -------------------------------------------------------------------------------- 1 | import { GroupCreatorModel } from './group-creator.model'; 2 | 3 | export interface GroupDetailsModel { 4 | id: number; 5 | name: string; 6 | rowVersion: string; 7 | creator: GroupCreatorModel; 8 | } 9 | -------------------------------------------------------------------------------- /client/src/data/groups/models/group-summary.model.ts: -------------------------------------------------------------------------------- 1 | export interface GroupSummaryModel { 2 | id: number; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/data/groups/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-group-command-result.model'; 2 | export * from './create-group-command.model'; 3 | export * from './group-creator.model'; 4 | export * from './group-details.model'; 5 | export * from './group-summary.model'; 6 | export * from './update-group-details-command-result.model'; 7 | export * from './update-group-details-command.model'; 8 | -------------------------------------------------------------------------------- /client/src/data/groups/models/update-group-details-command-result.model.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateGroupDetailsCommandResultModel { 2 | id: number; 3 | name: string; 4 | rowVersion: string; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/data/groups/models/update-group-details-command.model.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateGroupDetailsCommandModel { 2 | name: string; 3 | rowVersion: string; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import store from './store'; 5 | 6 | Vue.config.productionTip = false; 7 | 8 | new Vue({ 9 | router, 10 | store, 11 | render: (h) => h(App), 12 | }).$mount('#app'); 13 | -------------------------------------------------------------------------------- /client/src/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Router from 'vue-router'; 3 | import Home from './views/Home.vue'; 4 | import store from './store'; 5 | import * as authActions from '@/store/modules/auth/actions'; 6 | import * as groupActions from '@/store/modules/groups/actions'; 7 | import * as authGetters from '@/store/modules/auth/getters'; 8 | import { withLoader } from '@/shared/loader.functions'; 9 | 10 | Vue.use(Router); 11 | 12 | const router = new Router({ 13 | mode: 'history', 14 | base: process.env.BASE_URL, 15 | routes: [ 16 | { 17 | path: '/', 18 | name: 'home', 19 | component: Home 20 | }, 21 | { 22 | path: '/groups', 23 | name: 'groups', 24 | component: () => import('./views/Groups.vue'), 25 | meta: { requiresAuthentication: true }, 26 | }, 27 | { 28 | path: '/groups/:id', 29 | name: 'group-details', 30 | component: () => import('./views/GroupDetails.vue'), 31 | props: true, 32 | meta: { requiresAuthentication: true }, 33 | beforeEnter: async (to, from, next) => { 34 | await withLoader(async () => await store.dispatch(groupActions.types.LOAD_GROUP, to.params.id)); 35 | next(); 36 | } 37 | }, 38 | { 39 | path: '/about', 40 | name: 'about', 41 | // route level code-splitting 42 | // this generates a separate chunk (about.[hash].js) for this route 43 | // which is lazy-loaded when the route is visited. 44 | component: () => import(/* webpackChunkName: "about" */ './views/About.vue') 45 | }, 46 | ], 47 | }); 48 | 49 | router.beforeEach(async (to, from, next) => { 50 | if (!store.getters[authGetters.types.INFO].loaded) { 51 | await withLoader(async () => await store.dispatch(authActions.types.LOAD_INFO)); 52 | } 53 | if (to.matched.some(record => record.meta.requiresAuthentication) 54 | && !store.getters[authGetters.types.INFO].loggedIn) { 55 | window.location.href = `/auth/login?returnUrl=${window.location.href}`; 56 | } else { 57 | next(); 58 | } 59 | }); 60 | 61 | export default router; 62 | -------------------------------------------------------------------------------- /client/src/shared/loader.functions.ts: -------------------------------------------------------------------------------- 1 | import store from '@/store'; 2 | import * as loaderMutations from '@/store/modules/loader/mutations'; 3 | 4 | export async function withLoader(func: () => Promise): Promise { 5 | store.commit(loaderMutations.types.SHOW_LOADER); 6 | try { 7 | await func(); 8 | } finally { 9 | store.commit(loaderMutations.types.HIDE_LOADER); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /client/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex, { StoreOptions } from 'vuex'; 3 | import { RootState } from './state'; 4 | import { groups } from './modules/groups'; 5 | import { auth } from './modules/auth'; 6 | import { loader } from './modules/loader'; 7 | 8 | Vue.use(Vuex); 9 | 10 | 11 | const options: StoreOptions = { 12 | state: {}, 13 | modules: { 14 | auth, 15 | groups, 16 | loader 17 | } 18 | }; 19 | 20 | export default new Vuex.Store(options); 21 | -------------------------------------------------------------------------------- /client/src/store/modules/auth/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionTree } from 'vuex'; 2 | import { RootState } from '@/store/state'; 3 | import { AuthEndpoint } from '@/data/auth/auth-endpoint'; 4 | import { AuthState } from './state'; 5 | 6 | export const types = { 7 | LOAD_INFO: 'auth/loadInfo' 8 | }; 9 | 10 | export const makeActions = (authEndpoint: AuthEndpoint): ActionTree => { 11 | return { 12 | async loadInfo({ commit }): Promise { 13 | const authInfo = await authEndpoint.getAuthInfo(); 14 | if (!!authInfo) { 15 | commit('setUser', authInfo); 16 | } else { 17 | commit('setAnonymousUser'); 18 | } 19 | } 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/store/modules/auth/getters.ts: -------------------------------------------------------------------------------- 1 | import { AuthState } from './state'; 2 | import { GetterTree } from 'vuex'; 3 | import { RootState } from '@/store/state'; 4 | 5 | export const types = { 6 | INFO: 'auth/info' 7 | }; 8 | 9 | export const getters: GetterTree = { 10 | info(state: AuthState): AuthState { 11 | return { ...state }; 12 | } 13 | }; 14 | 15 | -------------------------------------------------------------------------------- /client/src/store/modules/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { RootState } from '@/store/state'; 3 | import { makeActions } from './actions'; 4 | import { mutations } from './mutations'; 5 | import { AuthService } from '@/data/auth/auth-service'; 6 | import { AuthState } from './state'; 7 | import { getters } from './getters'; 8 | 9 | export const auth: Module = { 10 | namespaced: true, 11 | actions: makeActions(new AuthService()), // TODO: maybe not the best place to create it 12 | mutations, 13 | getters, 14 | state: { 15 | loggedIn: false, 16 | loaded: false, 17 | info: null 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/store/modules/auth/mutations.ts: -------------------------------------------------------------------------------- 1 | import { AuthState } from './state'; 2 | import { MutationTree } from 'vuex'; 3 | import { AuthInfoModel } from '@/data/auth/models/auth-info-model'; 4 | 5 | export const mutations: MutationTree = { 6 | setUser(state: AuthState, authInfo: AuthInfoModel): void { 7 | state.loggedIn = true; 8 | state.loaded = true; 9 | state.info = authInfo; 10 | }, 11 | setAnonymousUser(state: AuthState): void { 12 | state.loggedIn = false; 13 | state.loaded = true; 14 | state.info = null; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/store/modules/auth/state.ts: -------------------------------------------------------------------------------- 1 | import { AuthInfoModel } from '@/data/auth/models/auth-info-model'; 2 | 3 | export interface AuthState { 4 | loggedIn: boolean; 5 | loaded: boolean; 6 | info: AuthInfoModel | null; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/store/modules/groups/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionTree } from 'vuex'; 2 | import { GroupsState } from './state'; 3 | import { RootState } from '@/store/state'; 4 | import { GroupsEndpoint } from '@/data/groups/groups-endpoint'; 5 | import { CreateGroupCommandModel } from '@/data/groups/models/create-group-command.model'; 6 | import { GroupDetailsModel } from '@/data/groups/models'; 7 | 8 | export const types = { 9 | LOAD_GROUP: 'groups/loadGroup', 10 | LOAD_GROUPS: 'groups/loadGroups', 11 | ADD_GROUP: 'groups/add', 12 | UPDATE_GROUP: 'groups/update', 13 | REMOVE_GROUP: 'groups/remove' 14 | }; 15 | 16 | export const makeActions = (groupsEndpoint: GroupsEndpoint): ActionTree => { 17 | return { 18 | async loadGroup({ commit }, groupId: number): Promise { 19 | const group = await groupsEndpoint.getById(groupId); 20 | commit('setGroupDetails', group); 21 | }, 22 | async loadGroups({ commit }): Promise { 23 | const groups = await groupsEndpoint.getAll(); 24 | commit('setGroups', groups); 25 | }, 26 | async add({ commit }, group: CreateGroupCommandModel): Promise { 27 | const result = await groupsEndpoint.add(group); 28 | const refreshedGroup = await groupsEndpoint.getById(result.id); 29 | commit('add', refreshedGroup); 30 | }, 31 | async update({ commit }, group: GroupDetailsModel): Promise { 32 | const updatedGroup = await groupsEndpoint.update(group.id, group); 33 | commit('update', updatedGroup); 34 | commit('updateGroupDetails', updatedGroup); 35 | }, 36 | async remove({ commit }, groupId: number): Promise { 37 | await groupsEndpoint.remove(groupId); 38 | commit('remove', groupId); 39 | } 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /client/src/store/modules/groups/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { GroupsState } from './state'; 3 | import { RootState } from '@/store/state'; 4 | import { makeActions } from './actions'; 5 | import { mutations } from './mutations'; 6 | import { GroupsService } from '@/data/groups/groups-service'; 7 | 8 | export const groups: Module = { 9 | namespaced: true, 10 | actions: makeActions(new GroupsService()), // TODO: maybe not the best place to create it 11 | mutations, 12 | state: { 13 | groups: [], 14 | groupDetails: null 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/store/modules/groups/mutations.ts: -------------------------------------------------------------------------------- 1 | import { GroupsState } from './state'; 2 | import { MutationTree } from 'vuex'; 3 | import { GroupSummaryModel } from '@/data/groups/models/group-summary.model'; 4 | import { GroupDetailsModel, UpdateGroupDetailsCommandResultModel } from '@/data/groups/models'; 5 | 6 | export const mutations: MutationTree = { 7 | setGroupDetails(state: GroupsState, group: GroupDetailsModel): void { 8 | state.groupDetails = { ...group }; 9 | }, 10 | updateGroupDetails(state: GroupsState, updateResult: UpdateGroupDetailsCommandResultModel): void { 11 | state.groupDetails = Object.assign({}, state.groupDetails, updateResult); 12 | }, 13 | setGroups(state: GroupsState, groups: GroupSummaryModel[]): void { 14 | state.groups = [...groups]; 15 | }, 16 | add(state: GroupsState, group: GroupSummaryModel): void { 17 | state.groups = [...state.groups, group]; 18 | }, 19 | update(state: GroupsState, group: GroupSummaryModel): void { 20 | const index = state.groups.findIndex(g => g.id === group.id); 21 | state.groups = [...state.groups.slice(0, index), group, ...state.groups.slice(index + 1, state.groups.length)]; 22 | }, 23 | remove(state: GroupsState, groupId: number): void { 24 | state.groups = state.groups.filter(g => g.id !== groupId); 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/store/modules/groups/state.ts: -------------------------------------------------------------------------------- 1 | import { GroupSummaryModel } from '@/data/groups/models/group-summary.model'; 2 | import { GroupDetailsModel } from '@/data/groups/models/group-details.model'; 3 | 4 | export interface GroupsState { 5 | groups: GroupSummaryModel[]; 6 | groupDetails: GroupDetailsModel | null; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/store/modules/loader/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import { LoaderState } from './state'; 3 | import { RootState } from '@/store/state'; 4 | import { mutations } from './mutations'; 5 | 6 | export const loader: Module = { 7 | namespaced: true, 8 | mutations, 9 | state: { 10 | isVisible: false 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/store/modules/loader/mutations.ts: -------------------------------------------------------------------------------- 1 | import { LoaderState } from './state'; 2 | import { MutationTree } from 'vuex'; 3 | 4 | export const types = { 5 | SHOW_LOADER: 'loader/show', 6 | HIDE_LOADER: 'loader/hide' 7 | }; 8 | 9 | export const mutations: MutationTree = { 10 | show(state: LoaderState): void { 11 | state.isVisible = true; 12 | }, 13 | hide(state: LoaderState): void { 14 | state.isVisible = false; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /client/src/store/modules/loader/state.ts: -------------------------------------------------------------------------------- 1 | export interface LoaderState { 2 | isVisible: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/store/state.ts: -------------------------------------------------------------------------------- 1 | export interface RootState { 2 | } 3 | -------------------------------------------------------------------------------- /client/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /client/src/views/GroupDetails.vue: -------------------------------------------------------------------------------- 1 | 39 | 87 | -------------------------------------------------------------------------------- /client/src/views/Groups.vue: -------------------------------------------------------------------------------- 1 | 4 | 31 | -------------------------------------------------------------------------------- /client/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | 20 | -------------------------------------------------------------------------------- /client/tests/unit/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { shallowMount } from '@vue/test-utils'; 3 | import HelloWorld from '@/components/HelloWorld.vue'; 4 | 5 | describe('HelloWorld.vue', () => { 6 | it('renders props.msg when passed', () => { 7 | const msg = 'new message'; 8 | const wrapper = shallowMount(HelloWorld, { 9 | propsData: { msg }, 10 | }); 11 | expect(wrapper.text()).to.include(msg); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "mocha", 17 | "chai" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "quotemark": [true, "single"], 13 | "indent": [true, "spaces", 2], 14 | "interface-name": false, 15 | "ordered-imports": false, 16 | "object-literal-sort-keys": false, 17 | "no-consecutive-blank-lines": false, 18 | "trailing-comma": false, 19 | "arrow-parens": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | proxy: { 4 | '^/api': { 5 | target: 'http://localhost:5000', 6 | ws: true, 7 | changeOrigin: true 8 | }, 9 | '^/auth': { 10 | target: 'http://localhost:5000', 11 | ws: true, 12 | changeOrigin: true 13 | }, 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.pch 68 | *.pdb 69 | *.pgc 70 | *.pgd 71 | *.rsp 72 | *.sbr 73 | *.tlb 74 | *.tli 75 | *.tlh 76 | *.tmp 77 | *.tmp_proj 78 | *.log 79 | *.vspscc 80 | *.vssscc 81 | .builds 82 | *.pidb 83 | *.svclog 84 | *.scc 85 | 86 | # Chutzpah Test files 87 | _Chutzpah* 88 | 89 | # Visual C++ cache files 90 | ipch/ 91 | *.aps 92 | *.ncb 93 | *.opendb 94 | *.opensdf 95 | *.sdf 96 | *.cachefile 97 | *.VC.db 98 | *.VC.VC.opendb 99 | 100 | # Visual Studio profiler 101 | *.psess 102 | *.vsp 103 | *.vspx 104 | *.sap 105 | 106 | # Visual Studio Trace Files 107 | *.e2e 108 | 109 | # TFS 2012 Local Workspace 110 | $tf/ 111 | 112 | # Guidance Automation Toolkit 113 | *.gpState 114 | 115 | # ReSharper is a .NET coding add-in 116 | _ReSharper*/ 117 | *.[Rr]e[Ss]harper 118 | *.DotSettings.user 119 | 120 | # JustCode is a .NET coding add-in 121 | .JustCode 122 | 123 | # TeamCity is a build add-in 124 | _TeamCity* 125 | 126 | # DotCover is a Code Coverage Tool 127 | *.dotCover 128 | 129 | # AxoCover is a Code Coverage Tool 130 | .axoCover/* 131 | !.axoCover/settings.json 132 | 133 | # Visual Studio code coverage results 134 | *.coverage 135 | *.coveragexml 136 | 137 | # NCrunch 138 | _NCrunch_* 139 | .*crunch*.local.xml 140 | nCrunchTemp_* 141 | 142 | # MightyMoose 143 | *.mm.* 144 | AutoTest.Net/ 145 | 146 | # Web workbench (sass) 147 | .sass-cache/ 148 | 149 | # Installshield output folder 150 | [Ee]xpress/ 151 | 152 | # DocProject is a documentation generator add-in 153 | DocProject/buildhelp/ 154 | DocProject/Help/*.HxT 155 | DocProject/Help/*.HxC 156 | DocProject/Help/*.hhc 157 | DocProject/Help/*.hhk 158 | DocProject/Help/*.hhp 159 | DocProject/Help/Html2 160 | DocProject/Help/html 161 | 162 | # Click-Once directory 163 | publish/ 164 | 165 | # Publish Web Output 166 | *.[Pp]ublish.xml 167 | *.azurePubxml 168 | # Note: Comment the next line if you want to checkin your web deploy settings, 169 | # but database connection strings (with potential passwords) will be unencrypted 170 | *.pubxml 171 | *.publishproj 172 | 173 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 174 | # checkin your Azure Web App publish settings, but sensitive information contained 175 | # in these scripts will be unencrypted 176 | PublishScripts/ 177 | 178 | # NuGet Packages 179 | *.nupkg 180 | # The packages folder can be ignored because of Package Restore 181 | **/[Pp]ackages/* 182 | # except build/, which is used as an MSBuild target. 183 | !**/[Pp]ackages/build/ 184 | # Uncomment if necessary however generally it will be regenerated when needed 185 | #!**/[Pp]ackages/repositories.config 186 | # NuGet v3's project.json files produces more ignorable files 187 | *.nuget.props 188 | *.nuget.targets 189 | 190 | # Microsoft Azure Build Output 191 | csx/ 192 | *.build.csdef 193 | 194 | # Microsoft Azure Emulator 195 | ecf/ 196 | rcf/ 197 | 198 | # Windows Store app package directories and files 199 | AppPackages/ 200 | BundleArtifacts/ 201 | Package.StoreAssociation.xml 202 | _pkginfo.txt 203 | *.appx 204 | 205 | # Visual Studio cache files 206 | # files ending in .cache can be ignored 207 | *.[Cc]ache 208 | # but keep track of directories ending in .cache 209 | !*.[Cc]ache/ 210 | 211 | # Others 212 | ClientBin/ 213 | ~$* 214 | *~ 215 | *.dbmdl 216 | *.dbproj.schemaview 217 | *.jfm 218 | *.pfx 219 | *.publishsettings 220 | orleans.codegen.cs 221 | 222 | # Including strong name files can present a security risk 223 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 224 | #*.snk 225 | 226 | # Since there are multiple workflows, uncomment next line to ignore bower_components 227 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 228 | #bower_components/ 229 | 230 | # RIA/Silverlight projects 231 | Generated_Code/ 232 | 233 | # Backup & report files from converting an old project file 234 | # to a newer Visual Studio version. Backup files are not needed, 235 | # because we have git ;-) 236 | _UpgradeReport_Files/ 237 | Backup*/ 238 | UpgradeLog*.XML 239 | UpgradeLog*.htm 240 | 241 | # SQL Server files 242 | *.mdf 243 | *.ldf 244 | *.ndf 245 | 246 | # Business Intelligence projects 247 | *.rdl.data 248 | *.bim.layout 249 | *.bim_*.settings 250 | 251 | # Microsoft Fakes 252 | FakesAssemblies/ 253 | 254 | # GhostDoc plugin setting file 255 | *.GhostDoc.xml 256 | 257 | # Node.js Tools for Visual Studio 258 | .ntvs_analysis.dat 259 | node_modules/ 260 | 261 | # TypeScript v1 declaration files 262 | typings/ 263 | 264 | # Visual Studio 6 build log 265 | *.plg 266 | 267 | # Visual Studio 6 workspace options file 268 | *.opt 269 | 270 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 271 | *.vbw 272 | 273 | # Visual Studio LightSwitch build output 274 | **/*.HTMLClient/GeneratedArtifacts 275 | **/*.DesktopClient/GeneratedArtifacts 276 | **/*.DesktopClient/ModelManifest.xml 277 | **/*.Server/GeneratedArtifacts 278 | **/*.Server/ModelManifest.xml 279 | _Pvt_Extensions 280 | 281 | # Paket dependency manager 282 | .paket/paket.exe 283 | paket-files/ 284 | 285 | # FAKE - F# Make 286 | .fake/ 287 | 288 | # JetBrains Rider 289 | .idea/ 290 | *.sln.iml 291 | 292 | # CodeRush 293 | .cr/ 294 | 295 | # Python Tools for Visual Studio (PTVS) 296 | __pycache__/ 297 | *.pyc 298 | 299 | # Cake - Uncomment if you are using it 300 | # tools/** 301 | # !tools/packages.config 302 | 303 | # Tabs Studio 304 | *.tss 305 | 306 | # Telerik's JustMock configuration file 307 | *.jmconfig 308 | 309 | # BizTalk build output 310 | *.btp.cs 311 | *.btm.cs 312 | *.odx.cs 313 | *.xsd.cs 314 | 315 | # OpenCover UI analysis results 316 | OpenCover/ 317 | 318 | # Azure Stream Analytics local run output 319 | ASALocalRun/ 320 | 321 | # MSBuild Binary and Structured Log 322 | *.binlog 323 | 324 | #MacOS stuff 325 | .DS_Store 326 | 327 | tools/ -------------------------------------------------------------------------------- /server/CodingMilitia.PlayBall.WebFrontend.BackForFront.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{735D533C-51F5-43E4-A000-C96D21B45069}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingMilitia.PlayBall.WebFrontend.BackForFront.Web", "src\CodingMilitia.PlayBall.WebFrontend.BackForFront.Web\CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.csproj", "{A2000AE7-144F-4E18-A0E3-D404844C7435}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{3FF747BE-4FDB-43E9-96F9-289FD512019A}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test", "tests\CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test\CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test.csproj", "{C04F94F1-EBE8-43E4-9729-AB15D535837B}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Debug|x86 = Debug|x86 19 | Release|Any CPU = Release|Any CPU 20 | Release|x64 = Release|x64 21 | Release|x86 = Release|x86 22 | EndGlobalSection 23 | GlobalSection(SolutionProperties) = preSolution 24 | HideSolutionNode = FALSE 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Debug|x64.ActiveCfg = Debug|Any CPU 30 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Debug|x64.Build.0 = Debug|Any CPU 31 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Debug|x86.ActiveCfg = Debug|Any CPU 32 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Debug|x86.Build.0 = Debug|Any CPU 33 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Release|x64.ActiveCfg = Release|Any CPU 36 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Release|x64.Build.0 = Release|Any CPU 37 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Release|x86.ActiveCfg = Release|Any CPU 38 | {A2000AE7-144F-4E18-A0E3-D404844C7435}.Release|x86.Build.0 = Release|Any CPU 39 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Debug|x64.ActiveCfg = Debug|Any CPU 42 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Debug|x64.Build.0 = Debug|Any CPU 43 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Debug|x86.ActiveCfg = Debug|Any CPU 44 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Debug|x86.Build.0 = Debug|Any CPU 45 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Release|x64.ActiveCfg = Release|Any CPU 48 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Release|x64.Build.0 = Release|Any CPU 49 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Release|x86.ActiveCfg = Release|Any CPU 50 | {C04F94F1-EBE8-43E4-9729-AB15D535837B}.Release|x86.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {A2000AE7-144F-4E18-A0E3-D404844C7435} = {735D533C-51F5-43E4-A000-C96D21B45069} 54 | {C04F94F1-EBE8-43E4-9729-AB15D535837B} = {3FF747BE-4FDB-43E9-96F9-289FD512019A} 55 | EndGlobalSection 56 | EndGlobal 57 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/core/sdk:3.1-alpine AS builder 2 | WORKDIR /app 3 | 4 | # Copy solution and restore as distinct layers to cache dependencies 5 | COPY ./src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/*.csproj ./src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/ 6 | COPY ./tests/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test/*.csproj ./tests/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test/ 7 | COPY *.sln ./ 8 | RUN dotnet restore 9 | 10 | # Publish the application 11 | COPY . ./ 12 | WORKDIR /app/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web 13 | RUN dotnet publish -c Release -o out 14 | 15 | # Build runtime image 16 | FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-alpine AS runtime 17 | WORKDIR /app 18 | COPY --from=builder /app/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/out . 19 | ENTRYPOINT ["dotnet", "CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.dll"] 20 | 21 | # Sample build command 22 | # docker build -t codingmilitia/webfrontend/bff . -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/Attempt01DictionaryPlusStringManipulation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 6 | { 7 | public class Attempt01DictionaryPlusStringManipulation : IProxiedApiRouteEndpointLookup 8 | { 9 | private readonly Dictionary _routeToEndpointMap; 10 | 11 | public Attempt01DictionaryPlusStringManipulation(Dictionary routeToEndpointMap) 12 | { 13 | _routeToEndpointMap = routeToEndpointMap; 14 | } 15 | 16 | public bool TryGet(PathString path, out string endpoint) 17 | { 18 | var pathString = path.Value; 19 | var basePathEnd = pathString.Substring(1, pathString.Length - 1).IndexOf('/'); 20 | var basePath = pathString.Substring(1, basePathEnd > 0 ? basePathEnd : pathString.Length - 1); 21 | return _routeToEndpointMap.TryGetValue(basePath, out endpoint); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/Attempt02ArrayIterationPlusPathBeginsWith.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 6 | { 7 | public class Attempt02ArrayIterationPlusPathBeginsWith : IProxiedApiRouteEndpointLookup 8 | { 9 | private readonly (string route, string endpoint)[] _routeCollection; 10 | 11 | public Attempt02ArrayIterationPlusPathBeginsWith(Dictionary routeToEndpointMap) 12 | { 13 | _routeCollection = routeToEndpointMap.Select(e => (route: $"/{e.Key}", endpoint: e.Value)).ToArray(); 14 | } 15 | 16 | public bool TryGet(PathString path, out string endpoint) 17 | { 18 | foreach (var e in _routeCollection) 19 | { 20 | if (path.StartsWithSegments(e.route)) 21 | { 22 | endpoint = e.endpoint; 23 | return true; 24 | } 25 | } 26 | 27 | endpoint = null; 28 | return false; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.AspNetCore.Http; 5 | 6 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 7 | { 8 | public class Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation : IProxiedApiRouteEndpointLookup 9 | { 10 | // The double dictionary strategy can be simplified if we're able to lookup directly with a ReadOnlySpan 11 | // Work in progress here -> https://github.com/dotnet/corefx/issues/31942 12 | 13 | private readonly Dictionary _routeToEndpointMap; 14 | private readonly Dictionary _routeMatcher; 15 | 16 | public Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation(Dictionary routeToEndpointMap) 17 | { 18 | _routeToEndpointMap = routeToEndpointMap ?? throw new ArgumentNullException(nameof(routeToEndpointMap)); 19 | _routeMatcher = _routeToEndpointMap 20 | .Keys 21 | .GroupBy( 22 | r => r.GetHashCode(), 23 | r => r) 24 | .ToDictionary( 25 | g => g.Key, 26 | g => g.ToArray()); 27 | } 28 | 29 | public bool TryGet(PathString path, out string endpoint) 30 | { 31 | endpoint = null; 32 | var pathSpan = path.Value.AsSpan(); 33 | var basePathEnd = pathSpan.Slice(1, pathSpan.Length - 1).IndexOf('/'); 34 | var basePath = pathSpan.Slice(1, basePathEnd > 0 ? basePathEnd : pathSpan.Length - 1); 35 | 36 | if (_routeMatcher.TryGetValue(string.GetHashCode(basePath), out var routes)) 37 | { 38 | var route = FindRoute(basePath, routes); 39 | return !(route is null) && _routeToEndpointMap.TryGetValue(route, out endpoint); 40 | } 41 | 42 | return false; 43 | } 44 | 45 | private static string FindRoute(ReadOnlySpan route, string[] routes) 46 | { 47 | foreach(var currentRoute in routes) 48 | { 49 | if (route.Equals(currentRoute, StringComparison.InvariantCultureIgnoreCase)) 50 | { 51 | return currentRoute; 52 | } 53 | } 54 | 55 | return null; 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.AspNetCore.Http; 5 | 6 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 7 | { 8 | public class Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation : IProxiedApiRouteEndpointLookup 9 | { 10 | // The int dictionary + complex value strategy can be simplified if we're able to lookup directly with a ReadOnlySpan 11 | // Work in progress here -> https://github.com/dotnet/corefx/issues/31942 12 | 13 | private readonly Dictionary _routeMatcher; 14 | 15 | public Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation(Dictionary routeToEndpointMap) 16 | { 17 | var tempRouteMatcher = new Dictionary>(); 18 | foreach (var entry in routeToEndpointMap) 19 | { 20 | var hashCode = entry.Key.GetHashCode(); 21 | if (tempRouteMatcher.TryGetValue(hashCode, out var route)) 22 | { 23 | route.Add(new Holder(entry.Key, entry.Value)); 24 | } 25 | else 26 | { 27 | tempRouteMatcher.Add(hashCode, new List {new Holder(entry.Key, entry.Value)}); 28 | } 29 | } 30 | 31 | _routeMatcher = tempRouteMatcher.ToDictionary(e => e.Key, e => e.Value.ToArray()); 32 | } 33 | 34 | public bool TryGet(PathString path, out string endpoint) 35 | { 36 | endpoint = null; 37 | var pathSpan = path.Value.AsSpan(); 38 | var basePathEnd = pathSpan.Slice(1, pathSpan.Length - 1).IndexOf('/'); 39 | var basePath = pathSpan.Slice(1, basePathEnd > 0 ? basePathEnd : pathSpan.Length - 1); 40 | 41 | if (_routeMatcher.TryGetValue(string.GetHashCode(basePath), out var routes)) 42 | { 43 | endpoint = FindRoute(basePath, routes); 44 | return endpoint != null; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | private static string FindRoute(ReadOnlySpan route, Holder[] routes) 51 | { 52 | foreach(var currentRoute in routes) 53 | { 54 | if (route.Equals(currentRoute.route, StringComparison.InvariantCultureIgnoreCase)) 55 | { 56 | return currentRoute.endpoint; 57 | } 58 | } 59 | return null; 60 | } 61 | 62 | private class Holder 63 | { 64 | public readonly string route; 65 | public readonly string endpoint; 66 | 67 | public Holder(string route, string endpoint) 68 | { 69 | this.route = route; 70 | this.endpoint = endpoint; 71 | } 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/Attempt0504WithAggressiveInlining.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.CompilerServices; 5 | using Microsoft.AspNetCore.Http; 6 | 7 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 8 | { 9 | public class Attempt0504WithAggressiveInlining : IProxiedApiRouteEndpointLookup 10 | { 11 | // The int dictionary + complex value strategy can be simplified if we're able to lookup directly with a ReadOnlySpan 12 | // Work in progress here -> https://github.com/dotnet/corefx/issues/31942 13 | 14 | private readonly Dictionary _routeMatcher; 15 | 16 | public Attempt0504WithAggressiveInlining(Dictionary routeToEndpointMap) 17 | { 18 | var tempRouteMatcher = new Dictionary>(); 19 | foreach (var entry in routeToEndpointMap) 20 | { 21 | var hashCode = entry.Key.GetHashCode(); 22 | if (tempRouteMatcher.TryGetValue(hashCode, out var route)) 23 | { 24 | route.Add(new Holder(entry.Key, entry.Value)); 25 | } 26 | else 27 | { 28 | tempRouteMatcher.Add(hashCode, new List {new Holder(entry.Key, entry.Value)}); 29 | } 30 | } 31 | 32 | _routeMatcher = tempRouteMatcher.ToDictionary(e => e.Key, e => e.Value.ToArray()); 33 | } 34 | 35 | public bool TryGet(PathString path, out string endpoint) 36 | { 37 | endpoint = null; 38 | var pathSpan = path.Value.AsSpan(); 39 | var basePathEnd = pathSpan.Slice(1, pathSpan.Length - 1).IndexOf('/'); 40 | var basePath = pathSpan.Slice(1, basePathEnd > 0 ? basePathEnd : pathSpan.Length - 1); 41 | 42 | if (_routeMatcher.TryGetValue(string.GetHashCode(basePath), out var routes)) 43 | { 44 | endpoint = FindRoute(basePath, routes); 45 | return endpoint != null; 46 | } 47 | 48 | return false; 49 | } 50 | 51 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 52 | private static string FindRoute(ReadOnlySpan route, Holder[] routes) 53 | { 54 | foreach (var currentRoute in routes) 55 | { 56 | if (route.Equals(currentRoute.route, StringComparison.InvariantCultureIgnoreCase)) 57 | { 58 | return currentRoute.endpoint; 59 | } 60 | } 61 | 62 | return null; 63 | } 64 | 65 | private class Holder 66 | { 67 | public readonly string route; 68 | public readonly string endpoint; 69 | 70 | public Holder(string route, string endpoint) 71 | { 72 | this.route = route; 73 | this.endpoint = endpoint; 74 | } 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/IProxiedApiRouteEndpointLookup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 4 | { 5 | public interface IProxiedApiRouteEndpointLookup 6 | { 7 | bool TryGet(PathString path, out string endpoint); 8 | } 9 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using BenchmarkDotNet.Attributes; 5 | using BenchmarkDotNet.Diagnostics.Windows.Configs; 6 | using BenchmarkDotNet.Running; 7 | using Microsoft.AspNetCore.Http; 8 | 9 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 10 | { 11 | public class Program 12 | { 13 | static void Main(string[] args) 14 | { 15 | DoSanityCheck(); 16 | 17 | var summary = BenchmarkRunner.Run(); 18 | } 19 | 20 | [RankColumn] 21 | [MemoryDiagnoser] 22 | //[InliningDiagnoser(logFailuresOnly: false)] //uncomment to see inlining results 23 | public class ProxiedApiRouteEndpointLookupBenchmark 24 | { 25 | [Params(10, 100, 1000)] public int MaxRoutes { get; set; } 26 | 27 | private PathString _path; 28 | private static Attempt01DictionaryPlusStringManipulation _attempt01; 29 | private static Attempt02ArrayIterationPlusPathBeginsWith _attempt02; 30 | private static Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation _attempt03; 31 | private static Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation _attempt04; 32 | private static Attempt0504WithAggressiveInlining _attempt05; 33 | 34 | [GlobalSetup] 35 | public void Setup() 36 | { 37 | var routeMap = CreateRouteMap(MaxRoutes); 38 | _path = $"/route{MaxRoutes - 1}/some/more/things/in/the/path"; 39 | 40 | _attempt01 = new Attempt01DictionaryPlusStringManipulation(routeMap); 41 | _attempt02 = new Attempt02ArrayIterationPlusPathBeginsWith(routeMap); 42 | _attempt03 = new Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation(routeMap); 43 | _attempt04 = new Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation(routeMap); 44 | _attempt05 = new Attempt0504WithAggressiveInlining(routeMap); 45 | } 46 | 47 | 48 | [Benchmark(Baseline = true)] 49 | public string Attempt01DictionaryPlusStringManipulation() 50 | { 51 | _attempt01.TryGet(_path, out var result); 52 | return result; 53 | } 54 | 55 | [Benchmark] 56 | public string Attempt02ArrayIterationPlusPathBeginsWith() 57 | { 58 | _attempt02.TryGet(_path, out var result); 59 | return result; 60 | } 61 | 62 | [Benchmark] 63 | public string Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation() 64 | { 65 | _attempt03.TryGet(_path, out var result); 66 | return result; 67 | } 68 | 69 | [Benchmark] 70 | public string Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation() 71 | { 72 | _attempt04.TryGet(_path, out var result); 73 | return result; 74 | } 75 | 76 | [Benchmark] 77 | public string Attempt0504WithAggressiveInlining() 78 | { 79 | _attempt05.TryGet(_path, out var result); 80 | return result; 81 | } 82 | } 83 | 84 | private static void DoSanityCheck() 85 | { 86 | const int maxRoutes = 100; 87 | var route = $"/route{maxRoutes - 1}/some/more/things/in/the/path"; 88 | 89 | var routeMap = CreateRouteMap(maxRoutes); 90 | 91 | var results = typeof(Program) 92 | .Assembly 93 | .GetTypes() 94 | .Where(t => t.IsClass && typeof(IProxiedApiRouteEndpointLookup).IsAssignableFrom(t)) 95 | .ToDictionary( 96 | t => t.Name, 97 | t => 98 | { 99 | var lookup = t 100 | .GetConstructor(new[] {routeMap.GetType()}) 101 | .Invoke(new object[] {routeMap}) as IProxiedApiRouteEndpointLookup; 102 | 103 | return lookup.TryGet(route, out var endpoint) ? endpoint : null; 104 | }); 105 | 106 | 107 | var expectedEndpoint = $"route{maxRoutes - 1}endpoint"; 108 | if (results.Values.Any(r => r != expectedEndpoint)) 109 | { 110 | var wrongResults = string.Join(Environment.NewLine, 111 | results.Where(r => r.Value != expectedEndpoint).Select(r => $"{r.Key}: {r.Value}")); 112 | Console.WriteLine( 113 | $"The following lookups are not working correctly:{Environment.NewLine}{wrongResults}"); 114 | 115 | throw new Exception("Something's not right!"); 116 | } 117 | } 118 | 119 | private static Dictionary CreateRouteMap(int maxRoutes) 120 | => Enumerable 121 | .Range(0, maxRoutes) 122 | .ToDictionary(i => $"route{i}", i => $"route{i}endpoint"); 123 | } 124 | } -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/ProxiedApiRouteEndpointLookup.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp3.0 6 | CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.ProxiedApiRouteEndpointLookup 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /server/benchmarks/ApiRouting/readme.md: -------------------------------------------------------------------------------- 1 | ``` ini 2 | 3 | BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362 4 | Intel Core i7-9700K CPU 3.60GHz (Coffee Lake), 1 CPU, 8 logical and 8 physical cores 5 | .NET Core SDK=3.0.100-preview9-014004 6 | [Host] : .NET Core 3.0.0-preview9-19423-09 (CoreCLR 4.700.19.42102, CoreFX 4.700.19.42104), 64bit RyuJIT 7 | DefaultJob : .NET Core 3.0.0-preview9-19423-09 (CoreCLR 4.700.19.42102, CoreFX 4.700.19.42104), 64bit RyuJIT 8 | 9 | 10 | ``` 11 | | Method | MaxRoutes | Mean | Error | StdDev | Ratio | RatioSD | Rank | Gen 0 | Gen 1 | Gen 2 | Allocated | 12 | |--------------------------------------------------------------------- |---------- |-------------:|------------:|-----------:|-------:|--------:|-----:|-------:|------:|------:|----------:| 13 | | **Attempt01DictionaryPlusStringManipulation** | **10** | **40.84 ns** | **0.1758 ns** | **0.1644 ns** | **1.00** | **0.00** | **1** | **0.0216** | **-** | **-** | **136 B** | 14 | | Attempt02ArrayIterationPlusPathBeginsWith | 10 | 165.82 ns | 1.3302 ns | 1.2443 ns | 4.06 | 0.04 | 5 | - | - | - | - | 15 | | Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation | 10 | 87.56 ns | 0.4744 ns | 0.4438 ns | 2.14 | 0.02 | 4 | - | - | - | - | 16 | | Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation | 10 | 74.57 ns | 0.6426 ns | 0.6011 ns | 1.83 | 0.01 | 3 | - | - | - | - | 17 | | Attempt0504WithAggressiveInlining | 10 | 71.48 ns | 1.4713 ns | 1.3762 ns | 1.75 | 0.03 | 2 | - | - | - | - | 18 | | | | | | | | | | | | | | 19 | | **Attempt01DictionaryPlusStringManipulation** | **100** | **44.05 ns** | **0.6808 ns** | **0.6368 ns** | **1.00** | **0.00** | **1** | **0.0216** | **-** | **-** | **136 B** | 20 | | Attempt02ArrayIterationPlusPathBeginsWith | 100 | 1,690.55 ns | 3.2448 ns | 3.0352 ns | 38.39 | 0.56 | 5 | - | - | - | - | 21 | | Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation | 100 | 90.44 ns | 0.6025 ns | 0.5636 ns | 2.05 | 0.03 | 4 | - | - | - | - | 22 | | Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation | 100 | 74.07 ns | 0.3004 ns | 0.2810 ns | 1.68 | 0.03 | 3 | - | - | - | - | 23 | | Attempt0504WithAggressiveInlining | 100 | 72.27 ns | 0.4487 ns | 0.3977 ns | 1.64 | 0.03 | 2 | - | - | - | - | 24 | | | | | | | | | | | | | | 25 | | **Attempt01DictionaryPlusStringManipulation** | **1000** | **41.60 ns** | **0.1311 ns** | **0.1162 ns** | **1.00** | **0.00** | **1** | **0.0216** | **-** | **-** | **136 B** | 26 | | Attempt02ArrayIterationPlusPathBeginsWith | 1000 | 17,640.81 ns | 103.7063 ns | 97.0069 ns | 423.86 | 2.53 | 5 | - | - | - | - | 27 | | Attempt03HashCodeBasedDoubleDictionaryPlusSpanManipulation | 1000 | 92.85 ns | 0.1979 ns | 0.1851 ns | 2.23 | 0.01 | 4 | - | - | - | - | 28 | | Attempt04HashCodeBasedDictionaryWithComplexValuePlusSpanManipulation | 1000 | 80.62 ns | 1.1987 ns | 1.1213 ns | 1.94 | 0.02 | 3 | - | - | - | - | 29 | | Attempt0504WithAggressiveInlining | 1000 | 77.01 ns | 0.0731 ns | 0.0610 ns | 1.85 | 0.01 | 2 | - | - | - | - | 30 | -------------------------------------------------------------------------------- /server/benchmarks/CodingMilitia.PlayBall.WebFrontend.BackForFront.Benchmarks.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProxiedApiRouteEndpointLookup", "ApiRouting\ProxiedApiRouteEndpointLookup.csproj", "{9393C171-E8F7-4BCC-A212-504CC2699DF9}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Debug|x64 = Debug|x64 12 | Debug|x86 = Debug|x86 13 | Release|Any CPU = Release|Any CPU 14 | Release|x64 = Release|x64 15 | Release|x86 = Release|x86 16 | EndGlobalSection 17 | GlobalSection(SolutionProperties) = preSolution 18 | HideSolutionNode = FALSE 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Debug|x64.ActiveCfg = Debug|Any CPU 24 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Debug|x64.Build.0 = Debug|Any CPU 25 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Debug|x86.ActiveCfg = Debug|Any CPU 26 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Debug|x86.Build.0 = Debug|Any CPU 27 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Release|x64.ActiveCfg = Release|Any CPU 30 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Release|x64.Build.0 = Release|Any CPU 31 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Release|x86.ActiveCfg = Release|Any CPU 32 | {9393C171-E8F7-4BCC-A212-504CC2699DF9}.Release|x86.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/ApiRouting/ProxiedApiRouteEndpointLookup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.AspNetCore.Http; 5 | 6 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.ApiRouting 7 | { 8 | public class ProxiedApiRouteEndpointLookup 9 | { 10 | private readonly Dictionary _routeToEndpointMap; 11 | 12 | public ProxiedApiRouteEndpointLookup(Dictionary routeToEndpointMap) 13 | { 14 | _routeToEndpointMap = routeToEndpointMap ?? throw new ArgumentNullException(nameof(routeToEndpointMap)); 15 | 16 | // TODO: we should enforce that the routes are actually base routes, having a single segment, 17 | // otherwise, given the implemented logic, such cases will never be matched. 18 | } 19 | 20 | public bool TryGet(PathString path, out string endpoint) 21 | { 22 | // If/when we get to index a string keyed dictionary with a span, improve this code to avoid allocations 23 | // Discussion here -> https://github.com/dotnet/corefx/issues/31942 24 | 25 | if (string.IsNullOrWhiteSpace(path.Value)) 26 | { 27 | endpoint = null; 28 | return false; 29 | } 30 | 31 | var pathString = path.Value; 32 | var basePathEnd = pathString.Substring(1, pathString.Length - 1).IndexOf('/'); 33 | var basePath = pathString.Substring(1, basePathEnd > 0 ? basePathEnd : pathString.Length - 1); 34 | return _routeToEndpointMap.TryGetValue(basePath, out endpoint); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/AuthTokenHelpers/AccessTokenHttpMessageHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using System.Net.Http.Headers; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Http; 6 | 7 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers 8 | { 9 | public class AccessTokenHttpMessageHandler : DelegatingHandler 10 | { 11 | private readonly IHttpContextAccessor _httpContextAccessor; 12 | 13 | public AccessTokenHttpMessageHandler(IHttpContextAccessor httpContextAccessor) 14 | { 15 | _httpContextAccessor = httpContextAccessor; 16 | } 17 | 18 | protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 19 | { 20 | var accessToken = await _httpContextAccessor.HttpContext.GetAccessTokenAsync(); 21 | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); 22 | return await base.SendAsync(request, cancellationToken); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/AuthTokenHelpers/CustomCookieAuthenticationEvents.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.AspNetCore.Authentication.Cookies; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers 7 | { 8 | public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents 9 | { 10 | private readonly ITokenRefresher _tokenRefresher; 11 | 12 | public CustomCookieAuthenticationEvents(ITokenRefresher tokenRefresher) 13 | { 14 | _tokenRefresher = tokenRefresher; 15 | } 16 | 17 | public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) 18 | { 19 | var result = await _tokenRefresher.TryRefreshTokenIfRequiredAsync( 20 | context.Properties.GetTokenValue("refresh_token"), 21 | context.Properties.GetTokenValue("expires_at"), 22 | CancellationToken.None); 23 | 24 | if (!result.IsSuccessResult) 25 | { 26 | context.RejectPrincipal(); 27 | await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); 28 | } 29 | else if (result.TokensRenewed) 30 | { 31 | context.Properties.UpdateTokenValue("access_token", result.AccessToken); 32 | context.Properties.UpdateTokenValue("refresh_token", result.RefreshToken); 33 | context.Properties.UpdateTokenValue("expires_at", result.ExpiresAt); 34 | context.ShouldRenew = true; 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/AuthTokenHelpers/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers 6 | { 7 | public static class Extensions 8 | { 9 | public static Task GetAccessTokenAsync(this HttpContext context) 10 | => context.GetTokenAsync("access_token"); 11 | } 12 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/AuthTokenHelpers/ITokenRefresher.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers 5 | { 6 | /// 7 | /// Provides an easy way to ensure the user's access token is up to date. 8 | /// 9 | public interface ITokenRefresher 10 | { 11 | /// 12 | /// Tries to refresh the current user's access token if required. 13 | /// 14 | /// The current refresh token. 15 | /// The current token expiration information. 16 | /// The async cancellation token. 17 | /// True if refresh is not needed or executed successfully, False otherwise. 18 | Task TryRefreshTokenIfRequiredAsync( 19 | string refreshToken, 20 | string expiresAt, 21 | CancellationToken ct); 22 | } 23 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/AuthTokenHelpers/TokenRefreshResult.cs: -------------------------------------------------------------------------------- 1 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers 2 | { 3 | public class TokenRefreshResult 4 | { 5 | private static readonly TokenRefreshResult NoRefreshNeededResult = 6 | new TokenRefreshResult(true, false, null, null, null); 7 | 8 | private static readonly TokenRefreshResult FailedResult = 9 | new TokenRefreshResult(false, false, null, null, null); 10 | 11 | protected TokenRefreshResult( 12 | bool isSuccessResult, 13 | bool tokensRenewed, 14 | string accessToken, 15 | string refreshToken, 16 | string expiresAt) 17 | { 18 | IsSuccessResult = isSuccessResult; 19 | TokensRenewed = tokensRenewed; 20 | AccessToken = accessToken; 21 | RefreshToken = refreshToken; 22 | ExpiresAt = expiresAt; 23 | } 24 | 25 | public bool IsSuccessResult { get; } 26 | public bool TokensRenewed { get; } 27 | public string AccessToken { get; } 28 | public string RefreshToken { get; } 29 | public string ExpiresAt { get; } 30 | 31 | public static TokenRefreshResult Success( 32 | string accessToken, 33 | string refreshToken, 34 | string expiresAt) 35 | { 36 | return new TokenRefreshResult(true, true, accessToken, refreshToken, expiresAt); 37 | } 38 | 39 | public static TokenRefreshResult Failed() => FailedResult; 40 | 41 | public static TokenRefreshResult NoRefreshNeeded() => NoRefreshNeededResult; 42 | } 43 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/AuthTokenHelpers/TokenRefresher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Configuration; 7 | using IdentityModel; 8 | using IdentityModel.Client; 9 | using Microsoft.AspNetCore.Authentication; 10 | using Microsoft.AspNetCore.Http; 11 | using Microsoft.Extensions.Logging; 12 | 13 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers 14 | { 15 | /// 16 | public class TokenRefresher : ITokenRefresher 17 | { 18 | private static readonly TimeSpan TokenRefreshThreshold = TimeSpan.FromSeconds(30); 19 | 20 | private readonly HttpClient _httpClient; 21 | private readonly IDiscoveryCache _discoveryCache; 22 | private readonly AuthServiceSettings _authServiceSettings; 23 | private readonly ILogger _logger; 24 | 25 | public TokenRefresher( 26 | HttpClient httpClient, 27 | IDiscoveryCache discoveryCache, 28 | AuthServiceSettings authServiceSettings, 29 | ILogger logger) 30 | { 31 | _httpClient = httpClient; 32 | _discoveryCache = discoveryCache; 33 | _authServiceSettings = authServiceSettings; 34 | _logger = logger; 35 | } 36 | 37 | /// 38 | public async Task TryRefreshTokenIfRequiredAsync( 39 | string refreshToken, 40 | string expiresAt, 41 | CancellationToken ct) 42 | { 43 | if (string.IsNullOrWhiteSpace(refreshToken)) 44 | { 45 | return TokenRefreshResult.Failed(); 46 | } 47 | 48 | if (!DateTime.TryParse(expiresAt, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var expiresAtDate) || expiresAtDate >= GetRefreshThreshold()) 49 | { 50 | return TokenRefreshResult.NoRefreshNeeded(); 51 | } 52 | 53 | var discovered = await _discoveryCache.GetAsync(); 54 | var tokenResult = await _httpClient.RequestRefreshTokenAsync( 55 | new RefreshTokenRequest 56 | { 57 | Address = discovered.TokenEndpoint, 58 | ClientId = _authServiceSettings.ClientId, 59 | ClientSecret = _authServiceSettings.ClientSecret, 60 | RefreshToken = refreshToken 61 | }, ct); 62 | 63 | if (tokenResult.IsError) 64 | { 65 | _logger.LogDebug( 66 | "Unable to refresh token, reason: {refreshTokenErrorDescription}", 67 | tokenResult.ErrorDescription); 68 | return TokenRefreshResult.Failed(); 69 | } 70 | 71 | var newAccessToken = tokenResult.AccessToken; 72 | var newRefreshToken = tokenResult.RefreshToken; 73 | var newExpiresAt = CalculateNewExpiresAt(tokenResult.ExpiresIn); 74 | 75 | return TokenRefreshResult.Success(newAccessToken, newRefreshToken, newExpiresAt); 76 | } 77 | 78 | private static string CalculateNewExpiresAt(int expiresIn) 79 | { 80 | // TODO: abstract usages of DateTime to ease unit tests 81 | return (DateTime.UtcNow + TimeSpan.FromSeconds(expiresIn)).ToString("o", CultureInfo.InvariantCulture); 82 | } 83 | 84 | private static DateTime GetRefreshThreshold() 85 | { 86 | // TODO: abstract usages of DateTime to ease unit tests 87 | return DateTime.UtcNow + TokenRefreshThreshold; 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Configuration/ApiSettings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Configuration 4 | { 5 | public class ApiSettings 6 | { 7 | public Uri Uri { get; set; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Configuration/AuthServiceSettings.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Configuration 3 | { 4 | public class AuthServiceSettings 5 | { 6 | public string Authority { get; set; } 7 | public bool RequireHttpsMetadata { get; set; } 8 | public string ClientId { get; set; } 9 | public string ClientSecret { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Configuration/ConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Configuration 4 | { 5 | public static class ConfigurationExtensions 6 | { 7 | public static T GetSection(this IConfiguration configuration, string key) 8 | => configuration.GetSection(key).Get(); 9 | 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Configuration/DataProtectionSettings.cs: -------------------------------------------------------------------------------- 1 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Configuration 2 | { 3 | public class DataProtectionSettings 4 | { 5 | public string Location { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Features/Auth/AuthController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Antiforgery; 2 | using Microsoft.AspNetCore.Http; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Features.Auth 6 | { 7 | [Route("auth")] 8 | public class AuthController : ControllerBase 9 | { 10 | private readonly IAntiforgery _antiForgery; 11 | 12 | public AuthController(IAntiforgery antiForgery) 13 | { 14 | _antiForgery = antiForgery; 15 | } 16 | 17 | [HttpGet] 18 | [Route("info")] 19 | public ActionResult GetInfo() 20 | { 21 | var tokens = _antiForgery.GetAndStoreTokens(HttpContext); 22 | HttpContext.Response.Cookies.Append( 23 | "XSRF-TOKEN", 24 | tokens.RequestToken, 25 | new CookieOptions() {HttpOnly = false}); //allow JS to grab the cookie to put it in the request header 26 | 27 | return new AuthInfoModel 28 | { 29 | Name = User.FindFirst("name").Value 30 | }; 31 | } 32 | 33 | [HttpGet] 34 | [Route("login")] 35 | public IActionResult Login([FromQuery] string returnUrl) 36 | { 37 | /* 38 | * No need to do anything here, as the auth middleware will take care of redirecting to IdentityServer4. 39 | * When the user is authenticated and gets back here, we can redirect to the desired url. 40 | */ 41 | return Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Features/Auth/AuthInfoModel.cs: -------------------------------------------------------------------------------- 1 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Features.Auth 2 | { 3 | public class AuthInfoModel 4 | { 5 | public string Name { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Logging; 11 | 12 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web 13 | { 14 | public class Program 15 | { 16 | public static void Main(string[] args) 17 | { 18 | CreateWebHostBuilder(args).Build().Run(); 19 | } 20 | 21 | public static IHostBuilder CreateWebHostBuilder(string[] args) => 22 | Host.CreateDefaultBuilder(args) 23 | .ConfigureWebHostDefaults(webBuilder => 24 | { 25 | webBuilder.UseStartup(); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Security/EnforceAuthenticatedUserMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Security 6 | { 7 | public class EnforceAuthenticatedUserMiddleware : IMiddleware 8 | { 9 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 10 | { 11 | if (!context.User.Identity.IsAuthenticated) 12 | { 13 | await context.ChallengeAsync(); 14 | return; 15 | } 16 | 17 | await next(context); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Security/ValidateAntiForgeryTokenMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Antiforgery; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Security 6 | { 7 | public class ValidateAntiForgeryTokenMiddleware : IMiddleware 8 | { 9 | private readonly IAntiforgery _antiforgery; 10 | 11 | public ValidateAntiForgeryTokenMiddleware(IAntiforgery antiforgery) 12 | { 13 | _antiforgery = antiforgery; 14 | } 15 | 16 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) 17 | { 18 | if (ShouldValidate(context)) 19 | { 20 | await _antiforgery.ValidateRequestAsync(context); 21 | } 22 | 23 | await next(context); 24 | } 25 | 26 | private static bool ShouldValidate(HttpContext context) 27 | { 28 | // as seen on https://github.com/aspnet/AspNetCore/blob/release/3.0/src/Mvc/Mvc.ViewFeatures/src/Filters/AutoValidateAntiforgeryTokenAuthorizationFilter.cs 29 | 30 | var method = context.Request.Method; 31 | return !(HttpMethods.IsGet(method) 32 | || HttpMethods.IsHead(method) 33 | || HttpMethods.IsTrace(method) 34 | || HttpMethods.IsOptions(method)); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/Startup.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.AuthTokenHelpers; 3 | using CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Configuration; 4 | using IdentityModel; 5 | using IdentityModel.Client; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using System.IdentityModel.Tokens.Jwt; 12 | using System.Net.Http; 13 | using System.Threading.Tasks; 14 | using Microsoft.AspNetCore.DataProtection; 15 | using System.IO; 16 | using System.Net; 17 | using CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.ApiRouting; 18 | using CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Security; 19 | using Microsoft.Extensions.Hosting; 20 | using ProxyKit; 21 | 22 | [assembly: ApiController] 23 | 24 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web 25 | { 26 | public class Startup 27 | { 28 | private readonly IConfiguration _configuration; 29 | 30 | public Startup(IConfiguration configuration) 31 | { 32 | _configuration = configuration; 33 | } 34 | 35 | // This method gets called by the runtime. Use this method to add services to the container. 36 | // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 37 | public void ConfigureServices(IServiceCollection services) 38 | { 39 | services 40 | .AddControllers() 41 | .AddControllersAsServices(); 42 | 43 | 44 | services.AddSingleton(serviceProvider => 45 | { 46 | var configuration = serviceProvider.GetRequiredService(); 47 | return configuration.GetSection("AuthServiceSettings"); 48 | }); 49 | 50 | services.AddSingleton(serviceProvider => 51 | { 52 | var authServiceConfig = serviceProvider.GetRequiredService(); 53 | var factory = serviceProvider.GetRequiredService(); 54 | return new DiscoveryCache(authServiceConfig.Authority, () => factory.CreateClient()); 55 | }); 56 | 57 | services 58 | .AddHttpClient(); 59 | 60 | services 61 | .AddTransient() 62 | .AddTransient() 63 | .AddHttpContextAccessor(); 64 | 65 | JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); 66 | 67 | services.AddAuthentication(options => 68 | { 69 | options.DefaultScheme = "Cookies"; 70 | options.DefaultChallengeScheme = "oidc"; 71 | }) 72 | .AddCookie("Cookies", options => { options.EventsType = typeof(CustomCookieAuthenticationEvents); }) 73 | .AddOpenIdConnect("oidc", options => 74 | { 75 | var authServiceConfig = _configuration.GetSection("AuthServiceSettings"); 76 | 77 | options.SignInScheme = "Cookies"; 78 | 79 | options.Authority = authServiceConfig.Authority; 80 | options.RequireHttpsMetadata = authServiceConfig.RequireHttpsMetadata; 81 | 82 | options.ClientId = authServiceConfig.ClientId; 83 | options.ClientSecret = authServiceConfig.ClientSecret; 84 | options.ResponseType = OidcConstants.ResponseTypes.Code; 85 | 86 | options.SaveTokens = true; 87 | options.GetClaimsFromUserInfoEndpoint = true; 88 | 89 | options.Scope.Add("GroupManagement"); 90 | options.Scope.Add(OidcConstants.StandardScopes.OfflineAccess); 91 | 92 | options.CallbackPath = "/auth/signin-oidc"; 93 | 94 | options.Events.OnRedirectToIdentityProvider = context => 95 | { 96 | if (!context.HttpContext.Request.Path.StartsWithSegments("/auth/login")) 97 | { 98 | context.HttpContext.Response.StatusCode = 401; 99 | context.HandleResponse(); 100 | } 101 | 102 | return Task.CompletedTask; 103 | }; 104 | }); 105 | 106 | services 107 | .AddAntiforgery(options => options.HeaderName = "X-XSRF-TOKEN"); 108 | 109 | var dataProtectionKeysLocation = 110 | _configuration.GetSection(nameof(DataProtectionSettings))?.Location; 111 | if (!string.IsNullOrWhiteSpace(dataProtectionKeysLocation)) 112 | { 113 | services 114 | .AddDataProtection() 115 | .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysLocation)); 116 | // TODO: encrypt the keys 117 | } 118 | 119 | services.AddProxy(); 120 | 121 | services.AddSingleton( 122 | new ProxiedApiRouteEndpointLookup( 123 | _configuration.GetSection>("ApiRoutes"))); 124 | 125 | services 126 | .AddSingleton() 127 | .AddSingleton(); 128 | } 129 | 130 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 131 | public void Configure(IApplicationBuilder app, IHostEnvironment env) 132 | { 133 | if (env.IsDevelopment()) 134 | { 135 | app.UseDeveloperExceptionPage(); 136 | } 137 | 138 | app.UseRouting(); 139 | app.UseAuthentication(); 140 | app.UseMiddleware(); 141 | app.UseMiddleware(); 142 | 143 | app.UseEndpoints(endpoints => 144 | { 145 | endpoints.MapControllers(); 146 | }); 147 | 148 | app.Map("/api", api => 149 | { 150 | api.RunProxy(async context => 151 | { 152 | var endpointLookup = context.RequestServices.GetRequiredService(); 153 | if (endpointLookup.TryGet(context.Request.Path, out var endpoint)) 154 | { 155 | var forwardContext = context 156 | .ForwardTo(endpoint) 157 | .CopyXForwardedHeaders(); 158 | 159 | var token = await context.GetAccessTokenAsync(); 160 | forwardContext.UpstreamRequest.SetBearerToken(token); 161 | 162 | return await forwardContext.Send(); 163 | } 164 | 165 | return new HttpResponseMessage(HttpStatusCode.NotFound); 166 | }); 167 | }); 168 | } 169 | } 170 | } -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | }, 9 | "AuthServiceSettings": { 10 | "Authority": "http://localhost:5004", 11 | "RequireHttpsMetadata": false, 12 | "ClientId": "WebFrontend", 13 | "ClientSecret": "secret" 14 | }, 15 | "ApiRoutes": { 16 | "groups": "http://localhost:5002" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/appsettings.DockerDevelopment.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | }, 9 | "AuthServiceSettings": { 10 | "Authority": "http://auth.playball.localhost", 11 | "RequireHttpsMetadata": false, 12 | "ClientId": "WebFrontend", 13 | "ClientSecret": "secret" 14 | }, 15 | "DataProtectionSettings": { 16 | "Location": "/var/lib/bff/dataprotectionkeys" 17 | }, 18 | "ApiRoutes": { 19 | "groups": "http://groupmanagement:80" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*" 8 | } 9 | -------------------------------------------------------------------------------- /server/tests/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test/ApiRouting/ProxiedApiRouteEndpointLookupTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.ApiRouting; 4 | using Xunit; 5 | 6 | namespace CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test.ApiRouting 7 | { 8 | public class ProxiedApiRouteEndpointLookupTests 9 | { 10 | [Fact] 11 | public void WhenUsingAnEmptyLookupDictionaryThenNoRouteIsMatched() 12 | { 13 | var lookup = new ProxiedApiRouteEndpointLookup(new Dictionary()); 14 | 15 | var found = lookup.TryGet("/non-existent-route", out _); 16 | 17 | Assert.False(found); 18 | } 19 | 20 | [Fact] 21 | public void WhenProvidingANullDictionaryThenTheConstructorThrowsArgumentNullException() 22 | { 23 | Assert.Throws(() => new ProxiedApiRouteEndpointLookup(null)); 24 | } 25 | 26 | [Theory] 27 | [InlineData("/non-existent-route")] 28 | [InlineData("/test-route-almost")] 29 | [InlineData("")] 30 | [InlineData(null)] 31 | public void WhenLookingUpANonExistentRouteThenNothingIsFound(string nonExistentRoute) 32 | { 33 | var lookup = new ProxiedApiRouteEndpointLookup(new Dictionary 34 | { 35 | ["test-route"] = "test-endpoint", 36 | ["another-test-route"]= "another-test-endpoint" 37 | }); 38 | 39 | var found = lookup.TryGet(nonExistentRoute, out _); 40 | 41 | Assert.False(found); 42 | } 43 | 44 | [Theory] 45 | [InlineData("/test-route", "test-endpoint")] 46 | [InlineData("/another-test-route/some/more/segments", "another-test-endpoint")] 47 | public void WhenLookingUpExistingRouteThenItIsFound(string route, string expectedEndpoint) 48 | { 49 | var lookup = new ProxiedApiRouteEndpointLookup( 50 | new Dictionary 51 | { 52 | ["test-route"] = "test-endpoint", 53 | ["another-test-route"]= "another-test-endpoint" 54 | }); 55 | 56 | var found = lookup.TryGet(route, out var endpoint); 57 | 58 | Assert.True(found); 59 | Assert.Equal(expectedEndpoint, endpoint); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /server/tests/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web.Test.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # https://spin.atomicobject.com/2017/08/24/start-stop-bash-background-process/ 4 | trap "exit" INT TERM ERR 5 | trap "kill 0" EXIT 6 | 7 | npm run serve --prefix ./client/ & 8 | 9 | dotnet watch --project ./server/src/CodingMilitia.PlayBall.WebFrontend.BackForFront.Web run --environment Development --urls http://localhost:5000 & 10 | 11 | wait --------------------------------------------------------------------------------