├── 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 |
12 | We're sorry but client doesn't work properly without JavaScript enabled. Please enable it to continue.
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/client/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Home
7 | |
About
8 |
9 | | Groups
10 |
11 |
12 | | Login
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
50 |
51 |
79 |
--------------------------------------------------------------------------------
/client/src/assets/football.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/components/groups/CreateGroup.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
Save
9 |
Discard
10 |
11 |
12 |
13 | Create new group
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/client/src/components/groups/GroupList.vue:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
58 |
--------------------------------------------------------------------------------
/client/src/components/groups/GroupListItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ group.name }}
4 | Details
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/client/src/components/shared/Loader.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
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 |
2 |
3 |
4 | About PlayBall
5 | PlayBall is the sample application for the series ASP.NET Core: From Zero to Overkill.
6 | What you're looking at right now is the web frontend, which is a single page application developed using Vue.js (and Bulma to try and make it less hideous).
7 | For more information, check out the series repo on GitHub .
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/client/src/views/GroupDetails.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{editableGroup.name}}
4 |
5 |
Group created by {{editableGroup.creator.name}}
6 |
7 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Remove
23 |
24 |
25 | Discard
26 |
27 |
28 | Save
29 |
30 |
31 |
32 | Edit
33 |
34 |
35 |
36 |
37 | Group not found!
38 |
39 |
87 |
--------------------------------------------------------------------------------
/client/src/views/Groups.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
31 |
--------------------------------------------------------------------------------
/client/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
PlayBall
4 |
5 |
6 |
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
--------------------------------------------------------------------------------