├── .babelrc
├── .github
└── workflows
│ └── build.yaml
├── .gitignore
├── FUNDING.yml
├── LICENSE
├── README.md
├── assets
├── actiologo.png
└── actioscreen.png
├── docs
├── README.md
├── auth.ts
├── basic.ts
├── decorators.md
├── docker-compose.yaml
├── dotenv
├── env.ts
├── example.test.ts
├── package-lock.json
├── package.json
├── seeding.md
├── system.ts
├── tsconfig.json
└── unexposed.ts
├── examples
└── README.md
├── jest.config.ts
├── jest.setup.ts
├── package-lock.json
├── package.json
├── src
├── api.test.ts
├── db.ts
├── env.ts
├── globalNamespace.test.ts
├── index.ts
├── injector.test.ts
├── injector.ts
├── microservices.test.ts
├── reflect.api.test.ts
├── reflect.api.ts
├── reflect.field.test.ts
├── reflect.field.ts
├── reflect.test.ts
├── reflect.ts
├── registrator.test.ts
├── registrator.ts
├── service
│ ├── admin
│ │ ├── index.ts
│ │ ├── models.ts
│ │ └── teardown.ts
│ ├── authentication
│ │ ├── README.md
│ │ ├── authentication.test.ts
│ │ ├── department
│ │ │ └── departmentList.ts
│ │ ├── index.ts
│ │ ├── models.ts
│ │ ├── oauth
│ │ │ ├── facebookLogin.ts
│ │ │ ├── oauthInfo.ts
│ │ │ └── registerOrLoginWithProvenIdentity.ts
│ │ ├── platform
│ │ │ └── platformList.ts
│ │ ├── role
│ │ │ └── roleList.ts
│ │ ├── token
│ │ │ └── tokenRead.ts
│ │ ├── user
│ │ │ ├── passwordChange.ts
│ │ │ ├── passwordChangeWithOld.ts
│ │ │ ├── passwordSendReset.ts
│ │ │ ├── tokenAdminGet.ts
│ │ │ ├── userCreateOrganization.ts
│ │ │ ├── userList.ts
│ │ │ ├── userLogin.ts
│ │ │ ├── userRegister.ts
│ │ │ ├── userSave.ts
│ │ │ ├── userSlugCheck.ts
│ │ │ ├── userUnGhost.ts
│ │ │ ├── verificationCodeSend.ts
│ │ │ └── verificationCodeVerify.ts
│ │ └── userSave.test.ts
│ ├── config
│ │ ├── README.md
│ │ ├── config.test.ts
│ │ ├── configRead.ts
│ │ ├── configSave.ts
│ │ ├── configSaveS2S.ts
│ │ ├── index.ts
│ │ ├── models.ts
│ │ ├── secretRead.ts
│ │ ├── secretSave.ts
│ │ └── secretSaveS2S.ts
│ ├── file
│ │ ├── README.md
│ │ ├── file.test.ts
│ │ ├── fileUpload.ts
│ │ ├── httpFileServe.ts
│ │ ├── index.ts
│ │ ├── models.ts
│ │ └── testfile.png
│ ├── keyvalue
│ │ ├── README.md
│ │ ├── get.ts
│ │ ├── index.ts
│ │ ├── kv.test.ts
│ │ ├── list.ts
│ │ ├── models.ts
│ │ └── set.ts
│ ├── payment
│ │ ├── README.md
│ │ ├── balance.ts
│ │ ├── charges.ts
│ │ ├── index.ts
│ │ ├── models.ts
│ │ ├── payWithBalance.ts
│ │ ├── payment.test.ts
│ │ ├── systemBalance.ts
│ │ └── topup.ts
│ └── system
│ │ ├── README.md
│ │ ├── apiRead.ts
│ │ ├── index.ts
│ │ ├── models.ts
│ │ ├── nodes.test.ts
│ │ └── nodesRead.ts
├── typeorm.ts
└── util.ts
├── tsconfig.dev.json
├── tsconfig.json
└── tslint.json
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-typescript"
5 | ],
6 | "plugins": [
7 | ["@babel/plugin-proposal-decorators", { "legacy": true }],
8 | ["@babel/plugin-proposal-class-properties", { "loose": true }],
9 | ["@babel/plugin-proposal-private-methods", { "loose": true }],
10 | ["@babel/plugin-proposal-private-property-in-object", { "loose": true }]
11 |
12 | ]
13 | }
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on: [push]
3 |
4 | jobs:
5 | backend-tests-run:
6 | runs-on: ubuntu-latest
7 | steps:
8 | - name: Checkout
9 | uses: actions/checkout@v3
10 |
11 | - name: Cache node modules
12 | id: cache
13 | uses: actions/cache@v3
14 | with:
15 | path: ./node_modules
16 | key: modules-${{ hashFiles('backend/package-lock.json') }}
17 |
18 | - name: Set up node
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: "16"
22 |
23 | - name: Launch docker containers
24 | run: |
25 | cp docs/docker-compose.yaml .
26 | docker-compose up --detach
27 | npm install
28 |
29 | - name: Npm install
30 | if: steps.cache.outputs.cache-hit != 'true'
31 | run: |
32 | npm install
33 |
34 | - name: Set up Run backend tests
35 | run: |
36 | printf "CONNECTION_NAME=$CONNECTION_NAME\n" > .env
37 | printf "SQL_USER=$SQL_USER\n" >> .env
38 | printf "SQL_PASSWORD=$SQL_PASSWORD\n" >> .env
39 | printf "SQL_NAME=$SQL_NAME\n" >> .env
40 | cat .env
41 | cp src/service/file/testfile.png /tmp/
42 | npm test
43 | env:
44 | CONNECTION_NAME: ${{ secrets.CONNECTION_NAME }}
45 | SQL_USER: ${{ secrets.SQL_USER }}
46 | SQL_PASSWORD: ${{ secrets.SQL_PASSWORD }}
47 | SQL_NAME: ${{ secrets.SQL_NAME }}
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | firebase-debug.log*
8 | firebase-debug.*.log*
9 | saved-data/
10 |
11 | # Firebase cache
12 | .firebase/
13 |
14 | # Firebase config
15 |
16 | # Uncomment this if you'd like others to create their own Firebase project.
17 | # For a team working on the same Firebase project(s), it is recommended to leave
18 | # it commented so all members can deploy to the same project(s) in .firebaserc.
19 | # .firebaserc
20 |
21 | # Runtime data
22 | pids
23 | *.pid
24 | *.seed
25 | *.pid.lock
26 |
27 | # Directory for instrumented libs generated by jscoverage/JSCover
28 | lib-cov
29 |
30 | # Coverage directory used by tools like istanbul
31 | coverage
32 |
33 | # nyc test coverage
34 | .nyc_output
35 |
36 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
37 | .grunt
38 |
39 | # Bower dependency directory (https://bower.io/)
40 | bower_components
41 |
42 | # node-waf configuration
43 | .lock-wscript
44 |
45 | # Compiled binary addons (http://nodejs.org/api/addons.html)
46 | build/Release
47 |
48 | # Dependency directories
49 | node_modules/
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional REPL history
58 | .node_repl_history
59 |
60 | # Output of 'npm pack'
61 | *.tgz
62 |
63 | # Yarn Integrity file
64 | .yarn-integrity
65 |
66 | # dotenv environment variables file
67 | .env
68 |
69 | lib
70 |
--------------------------------------------------------------------------------
/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [crufter]
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Actio
5 | The Node.js framework for monoliths and microservices.
6 |
7 |
8 |
9 |
10 | Actio is a modern, batteries included Node.js (Typescript) framework for your backend applications.
11 | It enables you to effortlessly switch between monolithic and microservices architectures.
12 |
13 | Start out with a monolith and turn it into microservices without changing your code once you need to scale!
14 |
15 | [Get started](./docs/README.md).
16 |
17 | ```sh
18 | npm i -S @crufters/actio
19 | ```
20 |
21 | ## Simple
22 |
23 | Actio values simplicity and elegance, because enjoying coding makes you more productive.
24 |
25 | ```typescript
26 | import { Service, Servicelike, startServer } from "@crufters/actio";
27 |
28 | interface MyEndpointRequest {
29 | name?: string;
30 | }
31 |
32 | @Service()
33 | class MyService implements Servicelike {
34 | constructor() {}
35 |
36 | // this method will be exposed as an HTTP endpoint
37 | async myEndpoint(req: MyEndpointRequest) {
38 | return { hi: req.name };
39 | }
40 |
41 | async _onInit() {
42 | console.log("MyService: _onInit runs whenever the server boots up.");
43 | }
44 | }
45 |
46 | startServer([MyService]);
47 | ```
48 |
49 | ## Dependencies made easy
50 |
51 | Your services can easily call each other just by accepting a constructor parameter:
52 |
53 | ```ts
54 | @Service()
55 | class MyService implements Servicelike {
56 | constructor(otherService: MyOtherService) {}
57 | }
58 | ```
59 |
60 | ## Monolith or microservices? Actio blurs the line
61 |
62 | Service calls are just function calls. Function calls become network calls simply by configuring Actio with envars:
63 |
64 | ```
65 | Without configuration, service calls are just normal function calls:
66 | --------------------------------
67 | | LoginService <-| <-| |
68 | | PaymentService ----| | |
69 | | OrderService ---------| |
70 | -------------------------------|
71 | instance address
72 | 0.0.0.0
73 | no Actio config
74 |
75 |
76 | With some lightweight configuration a true services based
77 | architecture can be achieved, without code changes:
78 |
79 | ------------------- -----------------
80 | | PaymentService |-------------------> | LoginService |
81 | | OrderService |-------------------> | |
82 | ------------------- -----------------
83 | instance address instance address
84 | 0.0.0.0 0.0.0.1
85 | envar LOGIN_SERVICE=0.0.0.1
86 |
87 | Calls to the login service become network calls automatically.
88 | ```
89 |
90 | ## Batteries included
91 |
92 | Actio is batteries included: it comes with services that help you bootstrap your system (but tries to not force you to use these) faster:
93 |
94 | - [x] [Authentication service](./src/service/authentication/README.md) for login, register, oauth (facebook etc.).
95 | - [x] [KeyValue service](./src/service/keyvalue/README.md) for saving unstructured data without creating yet another anemic endpoint/service.
96 | - [x] [File service](./src/service/file/README.md) for file upload. Upload to a local disk or to Google Storage etc. in production.
97 | - [x] [Config service](./src/service/config/README.md) for handling public configuration and secret values.
98 | - [x] [System service](./src/service/system/README.md) for inspecting the Actio runtime and enabling building tools upon Actio (such as API explorers etc.).
99 | - [x] Payment service: a double entry ledger system with Stripe and other payment provider support.
100 | - [ ] ...and many others that the community might find useful.
101 |
102 | ## Built with infrastructure in mind
103 |
104 | Real world apps need persistence and many other infrastructure elements.
105 | Actio manages your infra dependencies just like your service dependencies.
106 |
107 | - [x] Postgres
108 | - [ ] Redis
109 | - [ ] Many more coming
110 |
111 | ## Testing without the hassle
112 |
113 | Run integration tests easily including all of your services and infrastructure dependencies. No need for mocking.
114 |
115 | ## Namespaced for server savings
116 |
117 | Actio enables you to run multiple projects from the same single server by namespaces. Save on server and maintenance cost.
118 |
119 | ## Firm service boundaries
120 |
121 | Actio isolates your services - no more sidestepping of service boundaries, be it intentional or accidental.
122 | Each service is a black box for other services, which enable you to reimplement services without breaking depending services.
123 |
124 | The dependency injector makes sure your service tables live in different databases entirely - joins won't work across services.
125 | This and similar constraints enable you a seamless and refactor free transition to microservices - but also ensures your monolith doesn't became overly tightly coupled.
126 |
127 | ## Examples and tutorials
128 |
129 | For examples and tutorials see the [Getting started guide](./docs/README.md).
130 |
131 | ## Credits
132 |
133 | Inspired by other microservices systems such as [Micro](https://github.com/micro/micro) and the author's previous work with Asim Aslam.
134 | Author: [János Dobronszki](https://github.com/crufter).
135 | Contributors: [Dávid Dobronszki](https://github.com/Dobika), [Asim Aslam](https://github.com/asim), [Viktor Veress](https://github.com/vvik91).
136 |
--------------------------------------------------------------------------------
/assets/actiologo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crufters/actio/a76073680f1682a2e45315fe2ad748d58ca72baf/assets/actiologo.png
--------------------------------------------------------------------------------
/assets/actioscreen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crufters/actio/a76073680f1682a2e45315fe2ad748d58ca72baf/assets/actioscreen.png
--------------------------------------------------------------------------------
/docs/auth.ts:
--------------------------------------------------------------------------------
1 | import "./env.js";
2 |
3 | import { Service, startServer, AuthenticationService } from "@crufters/actio";
4 |
5 | interface MyEndpointRequest {
6 | token: string;
7 | }
8 |
9 | @Service()
10 | class MyService {
11 | auth: AuthenticationService;
12 |
13 | constructor(auth: AuthenticationService) {
14 | this.auth = auth;
15 | }
16 |
17 | async myEndpoint(req: MyEndpointRequest) {
18 | let trsp = await this.auth.tokenRead({ token: req.token });
19 | return { hi: trsp.token.user?.fullName };
20 | }
21 | }
22 |
23 | startServer([MyService]);
24 |
--------------------------------------------------------------------------------
/docs/basic.ts:
--------------------------------------------------------------------------------
1 | import { Service, Servicelike, startServer } from "@crufters/actio";
2 |
3 | interface MyEndpointRequest {
4 | name?: string;
5 | }
6 |
7 | @Service()
8 | class MyService implements Servicelike {
9 | constructor() {}
10 |
11 | // this endpoint will be exposed as a http endpoint
12 | async myEndpoint(req: MyEndpointRequest) {
13 | return { hi: req.name };
14 | }
15 |
16 | async _onInit() {
17 | console.log(
18 | "MyService: This callback runs when the server boots up. Perfect place to run do things like seeding the database."
19 | );
20 | }
21 | }
22 |
23 | startServer([MyService]);
24 |
--------------------------------------------------------------------------------
/docs/decorators.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](./README.md)
2 | # Decorators
3 |
4 | - [Decorators](#decorators)
5 | - [Unexposed](#unexposed)
6 | - [Raw](#raw)
7 | - [Field](#field)
8 | - [Param](#param)
9 | - [Endpoint](#endpoint)
10 |
11 | Quite a few decorators (`@Field`, `@Param` and `@Endpoint`) exists to help with reflection. For more information about that please see the [SystemService](../src/service/system/README.md).
12 |
13 | ## Unexposed
14 |
15 | The `@Unexposed` decorator makes your endpoint not accessible through HTTP. Your endpoint is still callable from other services as normal class methods.
16 |
17 | Prime example is `secretRead` in the [config service](../src/service/config/README.md):
18 |
19 | ```ts
20 | @Unexposed()
21 | secretRead(req: SecretReadRequest) {
22 | // ...
23 | }
24 | ```
25 |
26 | ## Raw
27 |
28 | The `@Raw` decorator tells Actio that it should pass in raw HTTP request and response objects to your endpoints instead of just JSON request types:
29 |
30 | ```ts
31 | @Raw()
32 | httpFileUpload(req: express.Request, rsp: express.Response) {
33 | // ...
34 | }
35 | ```
36 |
37 | ## Field
38 |
39 | The `@Field` decorator is meant to be used on class properties so the Actio runtime reflection knows about the class structure.
40 |
41 | ```ts
42 | export class PasswordChangeRequest {
43 | @Field()
44 | code?: string;
45 | @Field()
46 | newPassword?: string;
47 | }
48 | ```
49 |
50 | ## Param
51 |
52 | The `@Param` decorator is used to specify the types contained in higher order types for endpoint parameters.
53 |
54 | Take this example:
55 |
56 | ```ts
57 | myEndpoint(@Param({ type: number }) req: number[]) {}
58 | ```
59 |
60 | Since `Array` (`[]`) is a higher order type, Typescript reflection can't see the contained `number` type. `@Param` solves that.
61 |
62 | ## Endpoint
63 |
64 | The `@Endpoint` decorator is primarily used to specify the return type of an endpoint.
65 |
66 | Take this example:
67 |
68 | ```ts
69 | @Endpoint({
70 | returns: number
71 | })
72 | myEndpoint(): Promise
73 | ```
74 |
75 | Since `Promise` is a higher order type, TypeScript reflection can't see the contained `number` type. Specifying the contained type with the `returns` parameter helps with that.
76 |
--------------------------------------------------------------------------------
/docs/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | volumes:
4 | postgis-data:
5 | pgadmin:
6 |
7 | services:
8 | db:
9 | image: kartoza/postgis:14-3.2
10 | volumes:
11 | - postgis-data:/var/lib/postgresql
12 | environment:
13 | - POSTGRES_USER=postgres
14 | - POSTGRES_PASS=postgres
15 | # Add extensions you need to be enabled by default in the DB. Default are the five specified below
16 | - POSTGRES_MULTIPLE_EXTENSIONS=postgis,hstore,postgis_topology,postgis_raster,pgrouting
17 | ports:
18 | - "5432:5432"
19 | restart: on-failure
20 | healthcheck:
21 | test: "exit 0"
22 |
23 | # https://github.com/khezen/compose-postgres
24 | pgadmin:
25 | container_name: pgadmin4_container
26 | image: dpage/pgadmin4
27 | restart: always
28 | environment:
29 | PGADMIN_DEFAULT_EMAIL: admin@admin.com
30 | PGADMIN_DEFAULT_PASSWORD: postgres
31 | PGADMIN_CONFIG_SERVER_MODE: 'False'
32 | volumes:
33 | - pgadmin:/var/lib/pgadmin
34 | ports:
35 | - "5051:80"
--------------------------------------------------------------------------------
/docs/dotenv:
--------------------------------------------------------------------------------
1 | CONNECTION_NAME=127.0.0.1
2 | SQL_USER=postgres
3 | SQL_PASSWORD=postgres
4 | SQL_NAME=postgres
5 |
--------------------------------------------------------------------------------
/docs/env.ts:
--------------------------------------------------------------------------------
1 | // this dotenv import needs to be in a separate file due to
2 | // https://stackoverflow.com/questions/42817339/es6-import-happening-before-env-import
3 | import dotenv from "dotenv";
4 | dotenv.config({ path: "./dotenv" });
5 |
--------------------------------------------------------------------------------
/docs/example.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "@jest/globals";
2 | import { nanoid } from "nanoid";
3 | import { Service, Injector } from "@crufters/actio";
4 | import { DataSource, Entity, PrimaryColumn, Column } from "typeorm";
5 |
6 | @Entity()
7 | export class KV {
8 | @PrimaryColumn()
9 | id?: string;
10 |
11 | @Column()
12 | value?: string;
13 | }
14 |
15 | interface SetRequest {
16 | key?: string;
17 | value?: string;
18 | }
19 |
20 | interface GetRequest {
21 | key?: string;
22 | }
23 |
24 | interface GetResponse {
25 | value?: string;
26 | }
27 |
28 | @Service()
29 | class MyService {
30 | meta = {
31 | typeorm: {
32 | entities: [KV],
33 | },
34 | };
35 | constructor(private db: DataSource) {}
36 |
37 | // this method will be exposed as an HTTP endpoint
38 | async set(req: SetRequest) {
39 | await this.db
40 | .createEntityManager()
41 | .save(KV, { id: req.key, value: req.value });
42 | }
43 |
44 | async get(req: GetRequest): Promise {
45 | let v = await this.db
46 | .createQueryBuilder(KV, "kv")
47 | .where("kv.id = :id", { id: req.key })
48 | .getOne();
49 | return { value: v?.value };
50 | }
51 | }
52 |
53 | describe("my test", () => {
54 | var myService: MyService;
55 |
56 | test("setup", async () => {
57 | let namespace = "t_" + nanoid().slice(0, 7);
58 | let i = new Injector([MyService]);
59 | myService = await i.getInstance("MyService", namespace);
60 | });
61 |
62 | test("set get test", async () => {
63 | await myService.set({ key: "testkey", value: "testvalue" });
64 | let rsp = await myService.get({ key: "testkey" });
65 | expect(rsp.value).toBe("testvalue");
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "myproject",
3 | "version": "1.0.0",
4 | "description": "",
5 | "type": "module",
6 | "main": "index.js",
7 | "scripts": {
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "@types/express": "^4.17.17",
15 | "dotenv": "^16.0.3",
16 | "express": "^4.18.2"
17 | },
18 | "devDependencies": {
19 | "@types/googlemaps": "^3.43.3",
20 | "typescript": "^5.0.2"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/docs/seeding.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](./README.md)
2 |
3 | # Seeding and constants
4 |
5 | - [Seeding and constants](#seeding-and-constants)
6 | - [Constants](#constants)
7 | - [Seeding](#seeding)
8 |
9 | ## Constants
10 |
11 | In some of the service `models.ts` files the astute reader might spot constants similar to this:
12 |
13 | ```ts
14 | export const platformEmail = new Platform();
15 | platformEmail.id = "h4HZYn7FPpbdgVHBk1byc";
16 | platformEmail.slug = "email";
17 | platformEmail.name = "Email";
18 |
19 | export const platformFacebook = new Platform();
20 | platformFacebook.id = "wy_upG9BnzHvRg2WcSNgC";
21 | platformFacebook.slug = "facebook";
22 | platformFacebook.name = "Facebook";
23 |
24 | export const platforms = [
25 | platformEmail,
26 | platformFacebook
27 | ];
28 | ```
29 |
30 | This pattern is used for values that:
31 | - do not change dynamically
32 | - requires custom code support, so can't really be dynamic
33 | - need to be (intra-service) referenced in the databased for strong guarantees
34 |
35 | While hardcoding the id is admittedly not elegant, it enables both the backend and the frontend to easily reference the constants by their id.
36 |
37 | There is no need to call a platform list endpoint to simply display a dropdown.
38 |
39 | These values should be saved in an `_onInit` function.
40 |
41 | ## Seeding
42 |
43 | @todo
--------------------------------------------------------------------------------
/docs/system.ts:
--------------------------------------------------------------------------------
1 | import "./env.js";
2 |
3 | import {
4 | SystemService,
5 | AuthenticationService,
6 | startServer,
7 | } from "@crufters/actio";
8 |
9 | startServer([SystemService, AuthenticationService]);
10 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "outDir": "build",
6 | "rootDir": "./",
7 | "strict": true,
8 | "noImplicitAny": true,
9 | "moduleResolution": "node",
10 | "esModuleInterop": true,
11 | "experimentalDecorators": true,
12 | "emitDecoratorMetadata": true
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/docs/unexposed.ts:
--------------------------------------------------------------------------------
1 | import { Service, startServer, Unexposed } from "@crufters/actio";
2 |
3 | interface MyEndpointRequest {
4 | name?: string;
5 | }
6 |
7 | @Service()
8 | class MyService {
9 | constructor() {}
10 |
11 | // this method will be exposed as an HTTP endpoint
12 | async myEndpoint(req: MyEndpointRequest) {
13 | return { hi: req.name };
14 | }
15 |
16 | // this method WILL NOT be exposed as an HTTP endpoint
17 | @Unexposed()
18 | async notMyEndpoint(req: MyEndpointRequest) {
19 | return { hi: req.name };
20 | }
21 | }
22 |
23 | startServer([MyService]);
24 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | This was moved to the [docs folder](../docs/README.md)
2 |
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * For a detailed explanation regarding each configuration property and type check, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | export default {
7 | // All imported modules in your tests should be mocked automatically
8 | // automock: false,
9 |
10 | // Stop running tests after `n` failures
11 | bail: true,
12 |
13 | // The directory where Jest should store its cached dependency information
14 | // cacheDirectory: "/tmp/jest_rs",
15 |
16 | // Automatically clear mock calls, instances, contexts and results before every test
17 | clearMocks: true,
18 |
19 | // Indicates whether the coverage information should be collected while executing the test
20 | collectCoverage: true,
21 |
22 | // An array of glob patterns indicating a set of files for which coverage information should be collected
23 | // collectCoverageFrom: undefined,
24 |
25 | // The directory where Jest should output its coverage files
26 | coverageDirectory: "coverage",
27 |
28 | // An array of regexp pattern strings used to skip coverage collection
29 | // coveragePathIgnorePatterns: [
30 | // "/node_modules/"
31 | // ],
32 |
33 | // Indicates which provider should be used to instrument code for coverage
34 | coverageProvider: "v8",
35 |
36 | // A list of reporter names that Jest uses when writing coverage reports
37 | // coverageReporters: [
38 | // "json",
39 | // "text",
40 | // "lcov",
41 | // "clover"
42 | // ],
43 |
44 | // An object that configures minimum threshold enforcement for coverage results
45 | // coverageThreshold: undefined,
46 |
47 | // A path to a custom dependency extractor
48 | // dependencyExtractor: undefined,
49 |
50 | // Make calling deprecated APIs throw helpful error messages
51 | // errorOnDeprecated: false,
52 |
53 | // The default configuration for fake timers
54 | // fakeTimers: {
55 | // "enableGlobally": false
56 | // },
57 |
58 | // Force coverage collection from ignored files using an array of glob patterns
59 | // forceCoverageMatch: [],
60 |
61 | // A path to a module which exports an async function that is triggered once before all test suites
62 | // globalSetup: undefined,
63 |
64 | // A path to a module which exports an async function that is triggered once after all test suites
65 | // globalTeardown: undefined,
66 |
67 | // A set of global variables that need to be available in all test environments
68 | // globals: {},
69 |
70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
71 | // maxWorkers: "50%",
72 |
73 | // An array of directory names to be searched recursively up from the requiring module's location
74 | // moduleDirectories: [
75 | // "node_modules"
76 | // ],
77 |
78 | // An array of file extensions your modules use
79 | // moduleFileExtensions: [
80 | // "js",
81 | // "mjs",
82 | // "cjs",
83 | // "jsx",
84 | // "ts",
85 | // "tsx",
86 | // "json",
87 | // "node"
88 | // ],
89 |
90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
91 | // moduleNameMapper: {},
92 |
93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
94 | // modulePathIgnorePatterns: [],
95 |
96 | // Activates notifications for test results
97 | // notify: false,
98 |
99 | // An enum that specifies notification mode. Requires { notify: true }
100 | // notifyMode: "failure-change",
101 |
102 | // A preset that is used as a base for Jest's configuration
103 | // preset: undefined,
104 |
105 | // Run tests from one or more projects
106 | // projects: undefined,
107 |
108 | // Use this configuration option to add custom reporters to Jest
109 | // reporters: undefined,
110 |
111 | // Automatically reset mock state before every test
112 | // resetMocks: false,
113 |
114 | // Reset the module registry before running each individual test
115 | // resetModules: false,
116 |
117 | // A path to a custom resolver
118 | // resolver: undefined,
119 |
120 | // Automatically restore mock state and implementation before every test
121 | // restoreMocks: false,
122 |
123 | // The root directory that Jest should scan for tests and modules within
124 | // rootDir: undefined,
125 |
126 | // A list of paths to directories that Jest should use to search for files in
127 | // roots: [
128 | // ""
129 | // ],
130 |
131 | // Allows you to use a custom runner instead of Jest's default test runner
132 | // runner: "jest-runner",
133 |
134 | // The paths to modules that run some code to configure or set up the testing environment before each test
135 | setupFiles: ["/jest.setup.ts"],
136 |
137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
138 | // setupFilesAfterEnv: ["/jest.setup.ts"],
139 |
140 | // The number of seconds after which a test is considered as slow and reported as such in the results.
141 | // slowTestThreshold: 5,
142 |
143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
144 | // snapshotSerializers: [],
145 |
146 | // The test environment that will be used for testing
147 | // testEnvironment: "jest-environment-node",
148 |
149 | // Options that will be passed to the testEnvironment
150 | // testEnvironmentOptions: {},
151 |
152 | // Adds a location field to test results
153 | // testLocationInResults: false,
154 |
155 | // The glob patterns Jest uses to detect test files
156 | testMatch: ["**/lib/**/*.test.js"],
157 |
158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
159 | // testPathIgnorePatterns: [
160 | // "/node_modules/"
161 | // ],
162 |
163 | // The regexp pattern or array of patterns that Jest uses to detect test files
164 | // testRegex: [],
165 |
166 | // This option allows the use of a custom results processor
167 | // testResultsProcessor: undefined,
168 |
169 | // This option allows use of a custom test runner
170 | // testRunner: "jest-circus/runner",
171 |
172 | // A map from regular expressions to paths to transformers
173 | // transform: undefined,
174 |
175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
176 | // transformIgnorePatterns: [
177 | // "/node_modules/",
178 | // "\\.pnp\\.[^\\/]+$"
179 | // ],
180 |
181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
182 | // unmockedModulePathPatterns: undefined,
183 |
184 | // Indicates whether each individual test should be reported during the run
185 | // verbose: undefined,
186 |
187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
188 | // watchPathIgnorePatterns: [],
189 |
190 | // Whether to use watchman for file crawling
191 | // watchman: true,
192 | };
193 |
--------------------------------------------------------------------------------
/jest.setup.ts:
--------------------------------------------------------------------------------
1 | import "reflect-metadata";
2 |
3 | import dotenv from "dotenv";
4 |
5 | dotenv.config({ path: "./.env" });
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@crufters/actio",
3 | "version": "0.3.6",
4 | "type": "module",
5 | "description": "Actio is a lightweight, batteries included Node.js framework for your backend applications.",
6 | "main": "lib/index.js",
7 | "types": "lib/index.d.ts",
8 | "license": "AGPLv3",
9 | "files": [
10 | "lib/**/*"
11 | ],
12 | "scripts": {
13 | "lint": "./node_modules/.bin/tslint -p tsconfig.json",
14 | "build": "./node_modules/.bin/tsc",
15 | "serve": "npm run build && node ./lib/index.js",
16 | "pretty": "prettier --write \"./**/*.{ts,json,html,css,sass,scss}\"",
17 | "deploy": "",
18 | "logs": "",
19 | "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
20 | "test": "$(rm -r ./lib/* || true) && ./node_modules/.bin/tsc && jest"
21 | },
22 | "dependencies": {
23 | "@google-cloud/storage": "^6.10.1",
24 | "@sendgrid/mail": "^7.7.0",
25 | "@types/bcrypt": "^5.0.0",
26 | "@types/busboy": "^1.5.0",
27 | "@types/geojson": "^7946.0.10",
28 | "@types/slug": "^5.0.3",
29 | "@types/stripe-v3": "^3.1.28",
30 | "axios": "^1.4.0",
31 | "bcrypt": "^5.1.0",
32 | "busboy": "^1.6.0",
33 | "chalk": "^4.1.2",
34 | "dotenv": "^16.0.3",
35 | "express": "^4.18.2",
36 | "lodash": "^4.17.21",
37 | "nanoid": "^3.0.0",
38 | "reflect-metadata": "^0.1.13",
39 | "sinon": "^15.1.0",
40 | "slug": "^8.2.2",
41 | "stripe": "^12.6.0",
42 | "superagent": "^8.0.9",
43 | "ts-node": "^10.9.1",
44 | "typeorm": "^0.3.16"
45 | },
46 | "devDependencies": {
47 | "@babel/plugin-proposal-class-properties": "^7.18.6",
48 | "@babel/plugin-proposal-decorators": "^7.21.0",
49 | "@babel/plugin-proposal-private-methods": "^7.18.6",
50 | "@babel/plugin-proposal-private-property-in-object": "^7.21.0",
51 | "@babel/preset-env": "^7.21.5",
52 | "@babel/preset-typescript": "^7.21.5",
53 | "@types/express": "^4.17.17",
54 | "@types/lodash": "^4.14.194",
55 | "@types/node": "^20.2.3",
56 | "@types/node-cron": "^3.0.7",
57 | "@types/superagent": "^4.1.17",
58 | "@types/supertest": "^2.0.12",
59 | "form-data": "^4.0.0",
60 | "javascript-obfuscator": "^4.0.2",
61 | "jest": "^29.5.0",
62 | "prettier": "^2.8.8",
63 | "supertest": "^6.3.3",
64 | "ts-jest": "^29.1.0",
65 | "tslint": "^5.20.1",
66 | "typescript": "^5.0.4"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/api.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@jest/globals";
2 |
3 | import { createApp } from "./registrator.js";
4 | import { Service } from "./reflect.js";
5 | import { default as request } from "supertest";
6 | import _ from "lodash";
7 |
8 | @Service()
9 | class InvalidJSON {
10 | constructor() {}
11 |
12 | async hey() {
13 | return 1;
14 | }
15 | }
16 |
17 | test("invalid json", async () => {
18 | let appA = createApp([InvalidJSON]);
19 |
20 | let response = await request(appA).post("/InvalidJSON/hey").send(null);
21 | expect(response.status).toBe(200);
22 | expect(response.body).toEqual(1);
23 | });
24 |
--------------------------------------------------------------------------------
/src/db.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DataSource,
3 | DataSourceOptions,
4 | MixedList,
5 | EntitySchema,
6 | } from "typeorm";
7 |
8 | import env from "./env.js";
9 |
10 | let defaultConnectionOptions: DataSourceOptions = {
11 | name: "deflt",
12 | type: "postgres",
13 | host: env.postgres.connectionName,
14 | username: env.postgres.dbUser,
15 | password: env.postgres.dbPassword,
16 | database: env.postgres.dbName,
17 | // logging: true,
18 | synchronize: true,
19 | };
20 |
21 | interface ConnectorCallbacks {
22 | /** Invoked right after the database is created ie.
23 | * after the CREATE DATABASE command.
24 | */
25 | typeormDbCreated?: (conn: DataSource) => void;
26 | /** Invoked right after connecting to the database.
27 | */
28 | typeormConnectionInitialized?: (conn: DataSource) => void;
29 | }
30 |
31 | /** Connector manages database connections.
32 | */
33 | export class Connector {
34 | log = false;
35 | private connections: Map = new Map();
36 | private callbacks: ConnectorCallbacks = {};
37 |
38 | constructor(callbacks: ConnectorCallbacks) {
39 | this.callbacks = callbacks;
40 | }
41 |
42 | /** Connect to a postgres database and return
43 | * a typeorm connection.
44 | * It creates databases if they don't exist.
45 | */
46 | public connect = async (
47 | namespace: string,
48 | entities: MixedList>
49 | ): Promise => {
50 | let finalNamespace = namespace
51 | .replace(".", "_")
52 | .replace("-", "_")
53 | .toLowerCase();
54 | if (this.connections.has(finalNamespace)) {
55 | return this.connections.get(finalNamespace);
56 | }
57 |
58 | let copts: DataSourceOptions = {
59 | ...defaultConnectionOptions,
60 | name: finalNamespace as any,
61 | database: finalNamespace as any,
62 | entities: entities,
63 | };
64 |
65 | let connection: DataSource;
66 |
67 | try {
68 | connection = await this.connectTo(copts);
69 | } catch (err) {
70 | // is it a database not exist error?
71 | if (err.toString().includes("does not exist")) {
72 | // connect to the default database to create the other databases
73 | let c = await this.connectTo(defaultConnectionOptions);
74 | try {
75 | this.log && console.log("creating database", finalNamespace);
76 | await c.query("CREATE DATABASE " + finalNamespace);
77 | this.log && console.log("created database", finalNamespace);
78 | //await sleep(100);
79 | } catch (e) {
80 | this.log && console.log(e);
81 | }
82 | connection = await this.connectTo(copts);
83 | if (this.callbacks?.typeormDbCreated != undefined) {
84 | this.callbacks.typeormDbCreated(connection);
85 | }
86 | } else {
87 | throw err;
88 | }
89 | }
90 | return connection;
91 | };
92 |
93 | // connect to the default database
94 | private connectTo = async (
95 | connOpts: DataSourceOptions
96 | ): Promise => {
97 | let dbName = connOpts.database as string;
98 | let connection = new DataSource(connOpts);
99 |
100 | if (!connection.isInitialized) {
101 | connection = await connection.initialize();
102 | if (this.callbacks?.typeormConnectionInitialized != undefined) {
103 | this.callbacks?.typeormConnectionInitialized(connection);
104 | }
105 | this.connections.set(dbName, connection);
106 | }
107 |
108 | return connection;
109 | };
110 | }
111 |
112 | //function sleep(ms) {
113 | // return new Promise((resolve) => {
114 | // setTimeout(resolve, ms);
115 | // });
116 | //}
117 |
--------------------------------------------------------------------------------
/src/env.ts:
--------------------------------------------------------------------------------
1 | export const env = {
2 | isProd: process.env.IS_PRODUCTION,
3 | selfAddress: process.env.ACTIO_SELF_ADDRESS,
4 | postgres: {
5 | connectionName: process.env.CONNECTION_NAME,
6 | dbUser: process.env.SQL_USER,
7 | dbPassword: process.env.SQL_PASSWORD,
8 | dbName: process.env.SQL_NAME,
9 | },
10 | };
11 |
12 | export default env;
13 |
--------------------------------------------------------------------------------
/src/globalNamespace.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test, describe } from "@jest/globals";
2 |
3 | //import { Registrator } from "./registrator.js";
4 | //import express from "express";
5 | import { Service } from "./reflect.js";
6 | //import { default as request } from "supertest";
7 | import { Entity, PrimaryColumn, Column } from "typeorm";
8 | import { DataSource } from "typeorm";
9 | import { nanoid } from "nanoid";
10 | import { Injector } from "./injector.js";
11 |
12 | @Entity()
13 | export class E {
14 | @PrimaryColumn()
15 | id?: string;
16 |
17 | @Column({ nullable: true })
18 | something?: string;
19 | }
20 |
21 | @Service()
22 | class NsA {
23 | meta = {
24 | name: "nsa",
25 | typeorm: {
26 | entities: [E],
27 | },
28 | };
29 |
30 | constructor(private db: DataSource) {}
31 |
32 | async a(): Promise {
33 | let e = await this.db.createQueryBuilder(E, "e").getMany();
34 | if (e.length > 1) {
35 | throw new Error("too many entities");
36 | }
37 | return { hi: e[0].something };
38 | }
39 |
40 | async _onInit() {
41 | console.log("NsA init - should only happen once");
42 | this.db.transaction(async (manager) => {
43 | await manager.save(E, { id: "1", something: nanoid() });
44 | });
45 | }
46 | }
47 |
48 | @Service()
49 | class NsB {
50 | constructor(private a: NsA) {}
51 |
52 | async b() {
53 | return await this.a.a();
54 | }
55 | }
56 |
57 | @Service()
58 | class NsC {
59 | constructor(private a: NsA) {}
60 |
61 | async c() {
62 | return await this.a.a();
63 | }
64 | }
65 |
66 | describe("test global namespaces", () => {
67 | let b: NsB;
68 | let c: NsC;
69 |
70 | test("ns", async () => {
71 | let bNamespace = "t_" + nanoid().slice(0, 7);
72 | let cNamespace = "t_" + nanoid().slice(0, 7);
73 | let i = await new Injector([NsB, NsC]);
74 | i.log = true
75 | i.fixedNamespaces = new Map().set("NsA", "global");
76 | b = await i.getInstance("NsB", bNamespace);
77 | c = await i.getInstance("NsC", cNamespace);
78 |
79 | let rspB = await b.b();
80 | let rspC = await c.c();
81 | expect(await rspB).toEqual(await rspC);
82 | });
83 | });
84 |
85 | // @todo test multinode setup
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Service, Raw, Unexposed } from "./reflect.js";
2 | import { Field, FieldOptions } from "./reflect.field.js";
3 | import { Endpoint, EndpointOptions } from "./reflect.js";
4 | import { Injector } from "./injector.js";
5 | import { Registrator, startServer } from "./registrator.js";
6 | import { error, Error, copy, Servicelike, ServiceMeta } from "./util.js";
7 | import { AuthenticationService } from "./service/authentication/index.js";
8 | import { AdminService } from "./service/admin/index.js";
9 | import { PaymentService } from "./service/payment/index.js";
10 | import { ConfigService } from "./service/config/index.js";
11 | import { FileService } from "./service/file/index.js";
12 | import { SystemService } from "./service/system/index.js";
13 | import { KeyValueService } from "./service/keyvalue/index.js";
14 |
15 | export {
16 | Service,
17 | Raw,
18 | Unexposed,
19 | ServiceMeta,
20 | Servicelike,
21 | Injector,
22 | Registrator,
23 | error,
24 | Error,
25 | copy,
26 | AuthenticationService,
27 | AdminService,
28 | PaymentService,
29 | ConfigService,
30 | FileService,
31 | SystemService,
32 | Field,
33 | FieldOptions,
34 | Endpoint,
35 | EndpointOptions,
36 | startServer,
37 | KeyValueService,
38 | };
39 |
--------------------------------------------------------------------------------
/src/injector.test.ts:
--------------------------------------------------------------------------------
1 | import { Injector } from "./injector.js";
2 | import { Service } from "./reflect.js";
3 | import { expect, test } from "@jest/globals";
4 | import { nanoid } from "nanoid";
5 | import { random } from "lodash";
6 | import { startServer } from "./registrator.js";
7 | import { default as request } from "supertest";
8 |
9 | /** @todo test for circular dependencies */
10 |
11 | @Service()
12 | class B {
13 | constructor() {
14 | this.val = 1;
15 | this.id = nanoid();
16 | }
17 | val: Number;
18 | id: string;
19 |
20 | onInited: boolean;
21 | _onInit(): any {
22 | this.onInited = true;
23 | }
24 | }
25 |
26 | @Service()
27 | class A {
28 | b: B;
29 | id: string;
30 | constructor(dep: B) {
31 | this.b = dep;
32 | this.id = nanoid();
33 | }
34 |
35 | onInited: boolean;
36 | _onInit(): any {
37 | this.onInited = true;
38 | }
39 | }
40 |
41 | test("Injects deps", async () => {
42 | let i = new Injector([A, B]);
43 | let aInstance: A = await i.getInstance("A", "inject-deps");
44 | expect(aInstance.b?.val).toBe(1);
45 | });
46 |
47 | test("Injects deps multiple times", async () => {
48 | let i = new Injector([A, B]);
49 | let aInstance: A = await i.getInstance("A", "inject-deps-multiple-times");
50 | expect(aInstance.b?.val).toBe(1);
51 |
52 | aInstance = await i.getInstance("A", "inject-deps-multiple-times");
53 | expect(aInstance.b?.val).toBe(1);
54 | });
55 |
56 | test("Reuses deps", async () => {
57 | let i = new Injector([A, B]);
58 | let aInstance: A = await i.getInstance("A", "reuse-deps");
59 | let aInstance2: A = await i.getInstance("A", "reuse-deps");
60 | expect(aInstance.b?.id == aInstance2.b.id).toBe(true);
61 | });
62 |
63 | test("Class list is available immediately", async () => {
64 | let i = new Injector([A, B]);
65 | let list = i.availableClassNames();
66 | expect(list).toStrictEqual(["A", "B"]);
67 | });
68 |
69 | test("Class list is available immediately - implicit list", async () => {
70 | let i = new Injector([A]);
71 | let list = i.availableClassNames();
72 | expect(list).toStrictEqual(["A", "B"]);
73 | });
74 |
75 | @Service()
76 | class C {
77 | constructor() {}
78 | }
79 |
80 | test("Depless class works", async () => {
81 | let i = new Injector([C]);
82 | let cInstance: A = await i.getInstance("C", "depless-class-works");
83 | expect(cInstance != undefined).toBe(true);
84 | });
85 |
86 | test("Oninit gets called for top level type", async () => {
87 | let i = new Injector([A, B]);
88 | let aInstance: A = await i.getInstance(
89 | "A",
90 | "oninit-gets-called-for-top-level-type"
91 | );
92 | expect(aInstance.onInited).toBe(true);
93 | });
94 |
95 | test("Oninit gets called for second level type", async () => {
96 | let i = new Injector([A, B]);
97 | let bInstance: A = await i.getInstance(
98 | "B",
99 | "oninit-gets-called-for-second-level-type"
100 | );
101 | expect(bInstance.onInited).toBe(true);
102 | });
103 |
104 | test("Oninit gets called for second level type - implicit list", async () => {
105 | let i = new Injector([A]);
106 | let bInstance: A = await i.getInstance(
107 | "B",
108 | "oninit-gets-called-for-second-level-type-implicit-list"
109 | );
110 | expect(bInstance.onInited).toBe(true);
111 | });
112 |
113 | test("deps are loaded without explicitly passing them in (implicit list)", async () => {
114 | let i = new Injector([A]);
115 | let bInstance: A = await i.getInstance(
116 | "B",
117 | "deps-are-loaded-without-explicitly-passing-them-in-implicit-list"
118 | );
119 | expect(bInstance.onInited).toBe(true);
120 | });
121 |
122 | let counter = 0;
123 |
124 | @Service()
125 | class D {
126 | constructor() {}
127 |
128 | onInited: boolean;
129 | _onInit(): Promise {
130 | return new Promise((resolve) => {
131 | setTimeout(() => {
132 | counter++;
133 | resolve();
134 | }, random(500, 1800));
135 | });
136 | }
137 | }
138 |
139 | @Service()
140 | class E {
141 | d: D;
142 | constructor(dep: D) {
143 | this.d = dep;
144 | }
145 |
146 | onInited: boolean;
147 | _onInit(): Promise {
148 | return new Promise((resolve) => {
149 | setTimeout(() => {
150 | resolve();
151 | }, random(500, 1800));
152 | });
153 | }
154 | }
155 |
156 | test("Init only happens once", async () => {
157 | let i = new Injector([E]);
158 | i.log = false;
159 |
160 | // we make two request to the same type, but the init should only happen once
161 | // we make these requests in parallel
162 | let dInstance: Promise = i.getInstance("D", "init-once");
163 | let d1Instance: Promise = i.getInstance("D", "init-once");
164 |
165 | dInstance.then((e) => {
166 | expect(counter).toBe(1);
167 | });
168 |
169 | d1Instance.then((e) => {
170 | expect(counter).toBe(1);
171 | });
172 |
173 | await i.getInstance("D", "init-once");
174 | expect(counter).toBe(1);
175 | });
176 |
177 | test("Service instances are namespaced", async () => {
178 | let i = new Injector([A]);
179 | // we make two request to the same type, but the init should only happen once
180 | // we make these requests in parallel
181 | let bInstance: B = await i.getInstance("B", "ns1");
182 | let bInstance1: B = await i.getInstance("B", "ns2");
183 | expect(bInstance.id != bInstance1.id).toBe(true);
184 |
185 | let aInstance: A = await i.getInstance("A", "ns1");
186 | let aInstance1: A = await i.getInstance("A", "ns2");
187 | expect(aInstance.id != aInstance1.id).toBe(true);
188 | expect(aInstance.b.id != aInstance1.b.id).toBe(true);
189 | expect(aInstance.b.id == bInstance.id).toBe(true);
190 | expect(aInstance1.b.id == bInstance1.id).toBe(true);
191 | });
192 |
193 | test("Not found", async () => {
194 | let i = new Injector([A]);
195 | try {
196 | await i.getInstance("D", "not-found");
197 | } catch (e) {
198 | console.log(e);
199 | expect(JSON.stringify(e).includes("injector cannot find D")).toBe(true);
200 | }
201 | });
202 |
203 | @Service()
204 | class F {
205 | cname = "F";
206 | constructor() {}
207 | }
208 |
209 | @Service()
210 | class G {
211 | cname = "G";
212 | constructor() {}
213 | }
214 |
215 | @Service()
216 | class H {
217 | f: F;
218 | g: G;
219 |
220 | fProducer: (serviceName: string) => Promise;
221 | gProducer: (serviceName: string) => Promise;
222 | constructor(
223 | f: (serviceName: string) => Promise,
224 | g: (serviceName: string) => Promise
225 | ) {
226 | this.fProducer = f;
227 | this.gProducer = g;
228 | }
229 |
230 | async _onInit() {
231 | this.f = await this.fProducer("F");
232 | this.g = await this.gProducer("G");
233 | }
234 | }
235 |
236 | test("function based injection", async () => {
237 | // not how we pass in the producers
238 | let i = new Injector([F, G, H]);
239 | let h = await i.getInstance("H", "function-based-injection");
240 | expect(h.f?.cname).toBe("F");
241 | expect(h.g?.cname).toBe("G");
242 | // G F H + Function Function
243 | expect(i.availableClassNames().length).toBe(4);
244 | });
245 |
246 | @Service()
247 | class InjectorServer {
248 | inj: Injector;
249 | hasServ;
250 | constructor(injector: Injector) {
251 | this.inj = injector;
252 | }
253 |
254 | async _onInit() {
255 | this.hasServ = this.inj.server != undefined;
256 | }
257 |
258 | hasServer() {
259 | return { has: this.hasServ };
260 | }
261 | }
262 |
263 | test("injector has server", async () => {
264 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000;
265 |
266 | let serv = startServer([InjectorServer], randomPortNumber);
267 |
268 | let response = await request(serv.app)
269 | .post("/InjectorServer/hasServer")
270 | .send({})
271 | .retry(0);
272 | expect(response.status).toBe(200);
273 | expect(response.body).toEqual({ has: true });
274 |
275 | serv.server.close();
276 | });
277 |
--------------------------------------------------------------------------------
/src/microservices.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@jest/globals";
2 |
3 | import { createApp } from "./registrator.js";
4 | import { Service } from "./reflect.js";
5 | import { default as request } from "supertest";
6 | import { error } from "./util.js";
7 | import _ from "lodash";
8 |
9 | @Service()
10 | class MultiParam {
11 | constructor() {}
12 |
13 | async multiParam(a: number, b: string) {
14 | if (a == 1 && b == "2") {
15 | return "ok";
16 | }
17 | return "not ok";
18 | }
19 | }
20 |
21 | @Service()
22 | class MultiParamProxy {
23 | multiParamService: MultiParam;
24 | constructor(multiParamService: MultiParam) {
25 | this.multiParamService = multiParamService;
26 | }
27 |
28 | async multiParamProxy(a: number, b: string) {
29 | let rsp = await this.multiParamService.multiParam(a, b);
30 | return rsp;
31 | }
32 | }
33 |
34 | test("multiparam api call", async () => {
35 | let appA = createApp([MultiParam]);
36 |
37 | let response = await request(appA)
38 | .post("/MultiParam/multiParam")
39 | .send([1, "2"]);
40 |
41 | expect(response.status).toBe(200);
42 | expect(response.body).toEqual("ok");
43 |
44 | response = await request(appA).post("/MultiParam/multiParam").send([1, "3"]);
45 | expect(response.status).toBe(200);
46 | expect(response.body).toEqual("not ok");
47 | });
48 |
49 | test("multiParam microservice call", async () => {
50 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000;
51 | let appA = createApp([MultiParam]);
52 |
53 | let server = appA.listen(randomPortNumber);
54 |
55 | let appB = createApp([MultiParamProxy], {
56 | addresses: new Map().set(
57 | "MultiParamCall",
58 | "http://localhost:" + randomPortNumber
59 | ),
60 | });
61 |
62 | let response = await request(appB)
63 | .post("/MultiParamProxy/multiParamProxy")
64 | .send([1, "2"])
65 | .retry(0);
66 | expect(response.status).toBe(200);
67 | expect(response.body).toEqual("ok");
68 |
69 | response = await request(appB)
70 | .post("/multiParamProxy/multiParamProxy")
71 | .send([1, "3"])
72 | .retry(0);
73 | expect(response.status).toBe(200);
74 | expect(response.body).toEqual("not ok");
75 |
76 | server.close();
77 | });
78 |
79 | @Service()
80 | class Hi {
81 | constructor() {}
82 |
83 | async hi(dat) {
84 | return { hi: dat.hi };
85 | }
86 | }
87 |
88 | @Service()
89 | class HiProxy {
90 | hiService: Hi;
91 | constructor(hiService: Hi) {
92 | this.hiService = hiService;
93 | }
94 |
95 | async hiProxy(dat) {
96 | let rsp = await this.hiService.hi(dat);
97 | return { hi: rsp.hi + " hi there" };
98 | }
99 | }
100 |
101 | test("microservice call", async () => {
102 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000;
103 | let appA = createApp([Hi]);
104 |
105 | let server = appA.listen(randomPortNumber);
106 |
107 | let appB = createApp([HiProxy], {
108 | addresses: new Map().set("Hi", "http://localhost:" + randomPortNumber),
109 | });
110 |
111 | let response = await request(appB)
112 | .post("/HiProxy/hiProxy")
113 | .send({ hi: "hello" })
114 | .retry(0);
115 | expect(response.status).toBe(200);
116 | expect(response.body).toEqual({ hi: "hello hi there" });
117 |
118 | server.close();
119 | });
120 |
121 | test("microservice proof", async () => {
122 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000;
123 |
124 | const appB = createApp([HiProxy], {
125 | addresses: new Map().set("Hi", "http://localhost:" + randomPortNumber),
126 | });
127 |
128 | let response = await request(appB)
129 | .post("/Hi/hi")
130 | .send({ hi: "hello" })
131 | .retry(0);
132 | expect(response.status).toBe(500);
133 |
134 | response = await request(appB)
135 | .post("/Notexisting/hi")
136 | .send({ hi: "hello" })
137 | .retry(0);
138 | expect(response.status).toBe(404);
139 | });
140 |
141 | @Service()
142 | class DirectErr {
143 | constructor() {}
144 |
145 | async a(dat) {
146 | return { hi: dat.hi };
147 | }
148 |
149 | async directJsErr() {
150 | throw new Error("directJsErr");
151 | }
152 |
153 | async directFrameworkErr() {
154 | throw error("directFrameworkErr", 502);
155 | }
156 | }
157 |
158 | @Service()
159 | class ProxyErr {
160 | directErrService: DirectErr;
161 | constructor(directErrService: DirectErr) {
162 | this.directErrService = directErrService;
163 | }
164 |
165 | async proxyJsErr() {
166 | let rsp = await this.directErrService.directJsErr();
167 | return rsp;
168 | }
169 |
170 | async proxyFrameworkErr() {
171 | let rsp = await this.directErrService.directFrameworkErr();
172 | return rsp;
173 | }
174 |
175 | async directJsErr() {
176 | throw new Error("proxy:directJsErr");
177 | }
178 |
179 | async directFrameworkErr() {
180 | throw error("proxy:directFrameworkErr", 502);
181 | }
182 | }
183 |
184 | // @todo these tests are a bit hard to follow
185 | test("microservice error propagates", async () => {
186 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000;
187 |
188 | let appA = createApp([DirectErr]);
189 |
190 | let server = appA.listen(randomPortNumber);
191 |
192 | let appB = createApp([ProxyErr], {
193 | addresses: new Map().set(
194 | "DirectErr",
195 | "http://localhost:" + randomPortNumber
196 | ),
197 | });
198 |
199 | let response = await request(appB)
200 | .post("/ProxyErr/directJsErr")
201 | .send({})
202 | .retry(0);
203 | expect(response.status).toBe(500);
204 | expect(
205 | (response.body.error as string).startsWith(
206 | '{"stack":"Error: proxy:directJsErr'
207 | )
208 | ).toBe(true);
209 |
210 | response = await request(appB)
211 | .post("/ProxyErr/proxyJsErr")
212 | .send({ hi: "hello" })
213 | .retry(0);
214 | expect(response.status).toBe(500);
215 | expect(
216 | (response.body.error as string).startsWith('{"stack":"Error: directJsErr')
217 | ).toBe(true);
218 |
219 | response = await request(appB)
220 | .post("/ProxyErr/proxyFrameworkErr")
221 | .send({ hi: "hey" })
222 | .retry(0);
223 | expect(response.status).toBe(502);
224 | expect(response.body.error).toBe("directFrameworkErr");
225 |
226 | response = await request(appB)
227 | .post("/ProxyErr/directFrameworkErr")
228 | .send({ hi: "ho" })
229 | .retry(0);
230 | expect(response.status).toBe(502);
231 | expect(response.body.error).toBe("proxy:directFrameworkErr");
232 |
233 | server.close();
234 | });
235 |
236 | @Service()
237 | class Array {
238 | constructor() {}
239 |
240 | async sum(as: number[]): Promise {
241 | return _.sum(as);
242 | }
243 | }
244 |
245 | @Service()
246 | class ArrayProxy {
247 | arrayService: Array;
248 | constructor(arrayService: Array) {
249 | this.arrayService = arrayService;
250 | }
251 |
252 | async sumProxy(as: number[]) {
253 | let rsp = await this.arrayService.sum(as);
254 | return rsp;
255 | }
256 | }
257 |
258 | test("array api call", async () => {
259 | let appA = createApp([Array]);
260 |
261 | let response = await request(appA).post("/Array/sum").send([1]).retry(0);
262 | expect(response.status).toBe(200);
263 | expect(response.body).toEqual(1);
264 |
265 | response = await request(appA).post("/Array/sum").send([1, 2]).retry(0);
266 | expect(response.status).toBe(200);
267 | expect(response.body).toEqual(3);
268 | });
269 |
270 | test("array proxy api call", async () => {
271 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000;
272 | let appA = createApp([Array]);
273 |
274 | let server = appA.listen(randomPortNumber);
275 |
276 | let appB = createApp([ArrayProxy], {
277 | addresses: new Map().set("Array", "http://localhost:" + randomPortNumber),
278 | });
279 |
280 | let response = await request(appB)
281 | .post("/ArrayProxy/sumProxy")
282 | .send([1])
283 | .retry(0);
284 | expect(response.status).toBe(200);
285 | expect(response.body).toEqual(1);
286 |
287 | response = await request(appB)
288 | .post("/ArrayProxy/sumProxy")
289 | .send([1, 2])
290 | .retry(3);
291 | expect(response.status).toBe(200);
292 | expect(response.body).toEqual(3);
293 |
294 | server.close();
295 | });
296 |
--------------------------------------------------------------------------------
/src/reflect.api.test.ts:
--------------------------------------------------------------------------------
1 | import { getAPIJSON } from "./reflect.api.js";
2 | import { Field, Param } from "./reflect.field.js";
3 | import { Service } from "./reflect.js";
4 | import { Endpoint } from "./reflect.js";
5 | import { expect, test } from "@jest/globals";
6 |
7 | class M {
8 | @Field()
9 | ID: string;
10 |
11 | @Field()
12 | name?: string;
13 |
14 | @Field()
15 | age: number;
16 | }
17 |
18 | @Service()
19 | class N {
20 | constructor() {}
21 |
22 | @Endpoint({
23 | returns: null,
24 | })
25 | async doSomething(req: M): Promise {}
26 |
27 | @Endpoint({
28 | returns: M,
29 | })
30 | async doSomethingElse(@Param({ type: M }) req: M[]): Promise {
31 | return null;
32 | }
33 | }
34 |
35 | test("api def", async () => {
36 | console.log(N);
37 | let equalTo = {
38 | services: {
39 | N: {
40 | doSomething: {
41 | info: {
42 | methodName: "doSomething",
43 | options: {
44 | returns: null,
45 | },
46 | paramNames: ["_x"],
47 | paramTypes: ["M"],
48 | },
49 | paramOptions: [{}],
50 | },
51 | doSomethingElse: {
52 | info: {
53 | methodName: "doSomethingElse",
54 | options: {
55 | returns: "M",
56 | },
57 | paramNames: ["_x2"],
58 | paramTypes: ["Array"],
59 | },
60 | paramOptions: [
61 | {
62 | type: "M",
63 | },
64 | ],
65 | },
66 | },
67 | },
68 | types: {
69 | M: {
70 | ID: {
71 | data: { name: "ID", type: "String" },
72 | },
73 | name: {
74 | data: { name: "name", type: "String" },
75 | },
76 | age: {
77 | data: { name: "age", type: "Number" },
78 | },
79 | },
80 | },
81 | };
82 |
83 | let api = getAPIJSON();
84 | let json = {
85 | services: {
86 | N: api.services.N,
87 | },
88 | types: {
89 | M: api.types.M,
90 | },
91 | };
92 | expect(JSON.parse(JSON.stringify(json))).toEqual(equalTo);
93 |
94 | // do it again to test for idempotency
95 | api = getAPIJSON();
96 | json = {
97 | services: {
98 | N: api.services.N,
99 | },
100 | types: {
101 | M: api.types.M,
102 | },
103 | };
104 | expect(JSON.parse(JSON.stringify(json))).toEqual(equalTo);
105 | });
106 |
--------------------------------------------------------------------------------
/src/reflect.api.ts:
--------------------------------------------------------------------------------
1 | import { classNameToEndpointInfo, EndpointInfo } from "./reflect.js";
2 | import { FieldData, getParamOptions, ParamOptions } from "./reflect.field.js";
3 | import { listFieldClasses, listFields } from "./reflect.field.js";
4 |
5 | interface APIJSON {
6 | types: {
7 | [typeName: string]: {
8 | [fieldName: string]: {
9 | data: FieldData;
10 | };
11 | };
12 | };
13 | services: {
14 | [serviceName: string]: {
15 | [methodName: string]: {
16 | info?: EndpointInfo;
17 | // length equals to `info.paramTypes` field
18 | // contains null values for no options
19 | paramOptions: ParamOptions[];
20 | };
21 | };
22 | };
23 | }
24 |
25 | // this method is named JSON because
26 | // types are returned as strings and not actual
27 | // types (eg. [Function Array] becomes "Array")
28 | //
29 | // this way the return values is ready to be JSON stringified
30 | export function getAPIJSON(): APIJSON {
31 | let api: APIJSON = {
32 | services: {},
33 | types: {},
34 | };
35 |
36 | classNameToEndpointInfo.forEach((endpointInfos) => {
37 | endpointInfos.forEach((endpointInfo) => {
38 | const serviceName = endpointInfo.target.name;
39 | if (!api.services[serviceName]) {
40 | api.services[serviceName] = {};
41 | }
42 | let newInfo = {
43 | ...endpointInfo,
44 | options: {
45 | ...endpointInfo.options,
46 | },
47 | };
48 | newInfo.paramTypes = newInfo.paramTypes.map((type) => {
49 | return type.name;
50 | });
51 | if (newInfo.options.returns) {
52 | newInfo.options.returns = newInfo.options.returns?.name;
53 | }
54 |
55 | api.services[serviceName][endpointInfo.methodName] = {
56 | info: newInfo,
57 | paramOptions: endpointInfo.paramTypes.map((_, index) => {
58 | let ret = {
59 | ...getParamOptions(
60 | endpointInfo.target,
61 | endpointInfo.methodName,
62 | index
63 | ),
64 | };
65 | if (ret.type) {
66 | ret.type = ret.type?.name;
67 | }
68 | return ret;
69 | }),
70 | };
71 | });
72 | });
73 |
74 | listFieldClasses().forEach((clas) => {
75 | listFields(clas.name).forEach((field) => {
76 | let className = clas.name;
77 | if (!api.types[className]) {
78 | api.types[className] = {};
79 | }
80 | let data = {
81 | ...field,
82 | };
83 | data.type = data.type?.name;
84 | if (data.hint) {
85 | data.hint = data.hint?.name;
86 | }
87 | delete data.target;
88 | api.types[className][field.name] = {
89 | data: data,
90 | };
91 | });
92 | });
93 |
94 | return api;
95 | }
96 |
--------------------------------------------------------------------------------
/src/reflect.field.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@jest/globals";
2 | import {
3 | Field,
4 | getParamOptions,
5 | listClasses,
6 | listFields,
7 | Param,
8 | } from "./reflect.field.js";
9 | import { getMethodsForService, Service } from "./reflect.js";
10 | import { Endpoint } from "./reflect.js";
11 |
12 | class J {
13 | @Field()
14 | a: number;
15 |
16 | @Field()
17 | b: string;
18 | }
19 |
20 | test("field", async () => {
21 | console.log(J);
22 | let fields = listFields("J");
23 | expect(fields.length).toBe(2);
24 | expect(fields[0].target.constructor.name).toBe("J");
25 | expect(fields[0].name).toBe("a");
26 | expect(fields[0].type).toBe(Number);
27 | expect(fields[1].target.constructor.name).toBe("J");
28 | expect(fields[1].name).toBe("b");
29 | expect(fields[1].type).toBe(String);
30 |
31 | let classes = listClasses();
32 | expect(classes.find((c) => c.name == "J")).toBeTruthy();
33 | });
34 |
35 | class K {
36 | @Field({ hint: J })
37 | a: J[];
38 | }
39 |
40 | test("array field", async () => {
41 | console.log(K);
42 | let fields = listFields("K");
43 | expect(fields.length).toBe(1);
44 | expect(fields[0].target.constructor.name).toBe("K");
45 | expect(fields[0].name).toBe("a");
46 | expect(fields[0].type).toBe(Array);
47 | expect(fields[0].hint).toBe(J);
48 |
49 | let classes = listClasses();
50 | expect(classes.find((c) => c.name == "K")).toBeTruthy();
51 | });
52 |
53 | @Service()
54 | class L {
55 | constructor() {}
56 |
57 | @Endpoint({
58 | returns: J,
59 | })
60 | async doSomething(req: K): Promise {
61 | return { a: 1, b: "2" };
62 | }
63 |
64 | @Endpoint({
65 | returns: K,
66 | })
67 | async doSomething2(@Param({ type: J }) req: J[]): Promise {
68 | return { a: [{ a: 1, b: "2" }] };
69 | }
70 | }
71 |
72 | test("walk tree", async () => {
73 | console.log(L);
74 | let methods = getMethodsForService("L");
75 | expect(methods.length).toBe(2);
76 | expect(methods).toEqual([
77 | {
78 | target: L,
79 | methodName: "doSomething",
80 | paramNames: ["_x"], // ?
81 | paramTypes: [K],
82 | returnType: Promise,
83 | options: {
84 | returns: J,
85 | },
86 | },
87 | {
88 | target: L,
89 | methodName: "doSomething2",
90 | paramNames: ["_x2"], // ?
91 | paramTypes: [Array],
92 | returnType: Promise,
93 | options: {
94 | returns: K,
95 | },
96 | },
97 | ]);
98 | expect(getParamOptions(L, methods[1].methodName, 0)).toEqual({
99 | type: J,
100 | });
101 | });
102 |
103 | class LambdaField {
104 | @Field({ hint: () => Number })
105 | a: number[];
106 | }
107 |
108 | test("lambda field", async () => {
109 | console.log(LambdaField);
110 | let fields = listFields("LambdaField");
111 | expect(fields.length).toBe(1);
112 | expect(fields[0].hint).toBe(Number);
113 | });
114 |
--------------------------------------------------------------------------------
/src/reflect.field.ts:
--------------------------------------------------------------------------------
1 | export interface FieldData {
2 | target?: any;
3 | name?: string;
4 | required?: boolean;
5 | /**
6 | * Type returned by typescript reflection.
7 | * Do not set this, it's for internal usage.
8 | */
9 | type?: any;
10 | /**
11 | * Type hint. This is needed because array types can not be retrieved with typescript reflection, so we need some help from the caller:
12 | * https://stackoverflow.com/questions/35022658/how-do-i-get-array-item-type-in-typescript-using-the-reflection-api
13 | */
14 | hint?: any;
15 | /** Description for a field, it will appear in the API documentation/explorer */
16 | desc?: string;
17 | }
18 |
19 | let classMap = new Map();
20 | let fieldMap = new Map();
21 |
22 | export function listFieldClasses(): any[] {
23 | let ret = [];
24 | classMap.forEach((value) => {
25 | ret.push(value);
26 | });
27 | return ret;
28 | }
29 |
30 | export function listFields(target: any | string): FieldData[] {
31 | let key = target;
32 | if (typeof target != "string") {
33 | key = target.constructor.name;
34 | }
35 | if (!fieldMap.has(key)) {
36 | return [];
37 | }
38 | return fieldMap.get(key).map((opts) => {
39 | let ret = {
40 | ...opts,
41 | };
42 | // support lambda hints ie. @Field({hint: () => User})
43 | if (ret.hint?.name == "hint") {
44 | ret.hint = ret.hint();
45 | }
46 | return ret;
47 | });
48 | }
49 |
50 | export function listClasses(): any[] {
51 | let ret = [];
52 | classMap.forEach((value) => {
53 | ret.push(value);
54 | });
55 | return ret;
56 | }
57 |
58 | export type FieldOptions = Omit;
59 |
60 | /**
61 | * Field is for data class properties. Makes properties available to the Actio runtime for API docs and API client generation.
62 | * The parent class is also registered with the Actio runtime, but only the fields that are decorated with @Field are available.
63 | */
64 | export const Field = (options?: FieldOptions): PropertyDecorator => {
65 | return function (target, propertyKey) {
66 | classMap.set(target.constructor.name, target.constructor);
67 |
68 | let opts: FieldData = {
69 | target,
70 | };
71 | if (!options) {
72 | options = {};
73 | }
74 | opts.hint = options.hint;
75 |
76 | let name = target.constructor.name;
77 |
78 | let t = Reflect.getMetadata("design:type", target, propertyKey);
79 | if (!t) {
80 | throw `actio: Type for key '${String(
81 | propertyKey
82 | )}' in '${name}'' can not be determined.`;
83 | }
84 | opts.type = t;
85 |
86 | let list = fieldMap.get(name);
87 | if (!list) {
88 | list = [];
89 | fieldMap.set(name, list);
90 | }
91 | opts.name = options.name || String(propertyKey);
92 | list.push(opts);
93 | };
94 | };
95 |
96 | export interface ParamOptions {
97 | /**
98 | * The type contained in higher order types (`Array`, `Promise` etc.) in the parameter list of an endpoint.
99 | *
100 | * Due to Typescript reflection limitations, contained types (ie. the `User` in `Promise` or `string` in `string[]`) can't be automatically inferred.
101 | *
102 | * Example: If your endpoint is
103 | *
104 | * `async function getComments(@Param({type: string}) userIDs: string[]): Promise`
105 | *
106 | * This option and accurate type information is only required to enable API documentation and API client generation.
107 | **/
108 | type: any | any[];
109 | }
110 |
111 | // key format: __
112 | // key example: CommentService_getComments_0
113 | let paramOptions = new Map();
114 |
115 | /**
116 | * The Param parameter decorator is used to specify the type of a parameter for an endpoint when the
117 | * endpoint accepts higher order types (eg. arrays). See the `type` option in `ParamOptions` for more information.
118 | */
119 | export function Param(options: ParamOptions) {
120 | return function (target: any, methodName: string, parameterIndex: number) {
121 | const key = `${target.constructor.name}_${methodName}_${parameterIndex}`;
122 | paramOptions.set(key, options);
123 | };
124 | }
125 |
126 | export function getParamOptions(
127 | target: any,
128 | methodName: string,
129 | parameterIndex: number
130 | ): ParamOptions {
131 | const key = `${target.name}_${methodName}_${parameterIndex}`;
132 | return paramOptions.get(key);
133 | }
134 |
--------------------------------------------------------------------------------
/src/reflect.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@jest/globals";
2 | import { nanoid } from "nanoid";
3 | import {
4 | Endpoint,
5 | getDependencyGraph,
6 | isUnexposed,
7 | Service,
8 | Unexposed,
9 | getMethodParamsInfo,
10 | } from "./reflect.js";
11 |
12 | @Service()
13 | class G {}
14 |
15 | @Service()
16 | class F {}
17 |
18 | @Service()
19 | class E {
20 | constructor(dep: G) {}
21 | }
22 |
23 | @Service()
24 | class D {
25 | constructor(dep: E) {}
26 | }
27 |
28 | @Service()
29 | class C {
30 | constructor(dep: D, dep1: F) {}
31 | }
32 |
33 | @Service()
34 | class B {
35 | constructor() {
36 | this.val = 1;
37 | this.id = nanoid();
38 | }
39 | val: Number;
40 | id: string;
41 |
42 | onInited: boolean;
43 | _onInit(): any {
44 | this.onInited = true;
45 | }
46 | }
47 |
48 | @Service()
49 | class A {
50 | b: B;
51 | constructor(dep: B, dep2: C) {
52 | this.b = dep;
53 | }
54 |
55 | onInited: boolean;
56 | _onInit(): any {
57 | this.onInited = true;
58 | }
59 | }
60 |
61 | test("Test dependency graph", async () => {
62 | let deps = getDependencyGraph(A);
63 |
64 | expect(deps.length).toBe(6);
65 | expect(deps[0].name).toBe("B");
66 | expect(deps[1].name).toBe("C");
67 | expect(deps[2].name).toBe("D");
68 | expect(deps[3].name).toBe("F");
69 | expect(deps[4].name).toBe("E");
70 | expect(deps[5].name).toBe("G");
71 | });
72 |
73 | @Service()
74 | class H {
75 | constructor() {}
76 |
77 | a() {
78 | return 1;
79 | }
80 |
81 | @Unexposed()
82 | b() {
83 | return 2;
84 | }
85 | }
86 |
87 | test("unexposed", async () => {
88 | expect(isUnexposed(H, "a")).toBe(false);
89 | expect(isUnexposed(H, "b")).toBe(true);
90 | });
91 |
92 | @Service()
93 | class I {
94 | constructor() {}
95 |
96 | @Endpoint()
97 | a(a: number, b: string, c: C) {
98 | return 1;
99 | }
100 | }
101 |
102 | test("method types", async () => {
103 | console.log(I);
104 | let inf = getMethodParamsInfo("I");
105 | expect(inf.length).toBe(1);
106 | expect(inf[0].target.name).toBe("I");
107 | expect(inf[0].methodName).toBe("a");
108 | expect(inf[0].paramNames.length).toBe(3);
109 | expect(inf[0].paramNames).toEqual(["_a", "b", "c"]);
110 | expect(inf[0].paramTypes.length).toBe(3);
111 | expect(inf[0].paramTypes[0]).toBe(Number);
112 | expect(inf[0].paramTypes[1]).toBe(String);
113 | expect(inf[0].paramTypes[2]).toBe(C);
114 | expect(inf[0].paramTypes[2].name).toBe("C");
115 | });
116 |
--------------------------------------------------------------------------------
/src/reflect.ts:
--------------------------------------------------------------------------------
1 | import "reflect-metadata";
2 |
3 | import { ServiceMeta } from "./util.js";
4 | import _ from "lodash";
5 |
6 | // The decorators that are empty here only exist to trigger
7 | // Reflect.getMetadata("design:paramtypes", ...) working properly
8 | // To see the effect how having a decorator causes
9 | // paramtypes to work play around with this gist:
10 | // https://gist.github.com/crufter/5fac85071864c41775cc3079015aac71
11 |
12 | /**
13 | * Makes the methods of a class available as API endpoints.
14 | * See `startServer` function or the `Registrator` class for a more manual
15 | * way to start a server.
16 | *
17 | * The constructor of your service
18 | * can accept the following list of allowed dependencies:
19 | * - other services marked with this decorator
20 | * - import { DataSource } from "typeorm" and other types handled by handlers
21 | *
22 | * Services should not panic if they are not supplied with all the dependencies
23 | * at the time of construction.
24 | */
25 | export const Service = (): ClassDecorator => {
26 | return (target) => {
27 | // this does nothing, ignore it
28 | classDecoratorSaveParameterTypes(target);
29 | };
30 | };
31 |
32 | export interface EndpointOptions {
33 | /**
34 | * The types contained in higher order types (`Array`, `Promise` etc.) in the parameter list of an endpoint.
35 | *
36 | * Due to Typescript reflection limitations, contained types (ie. the `User` in `Promise` or `string` in `string[]`/`Array`) can't be automatically inferred.
37 | *
38 | * Example: If your endpoint is
39 | *
40 | * `async function getUser(userID: string): Promise`
41 | *
42 | * then `returns` should be `User`.
43 | *
44 | * This option is only required to enable API documentation and API client generation.
45 | * See also the `containedTypes` option for a similar concept but for the endpoint parameter types.
46 | */
47 | returns: any;
48 | }
49 |
50 | /**
51 | * The `Endpoint` decorator makes a method visible to API docs and API client generation.
52 | * Your endpoint will function fine without this decorator.
53 | */
54 | export const Endpoint = (options?: EndpointOptions): MethodDecorator => {
55 | return (target, propertyKey, descriptor) => {
56 | methodDecoratorSaveParameterTypes(options, target, propertyKey as string);
57 | };
58 | };
59 |
60 | export const Type = (): ClassDecorator => {
61 | return (target) => {
62 | // this does nothing, ignore it
63 | classDecoratorSaveParameterTypes(target);
64 | };
65 | };
66 |
67 | export interface EndpointInfo {
68 | target: any; // class
69 | methodName: string; // method name
70 | paramNames: any[];
71 | paramTypes: any[];
72 | returnType: any;
73 | options?: EndpointOptions;
74 | }
75 |
76 | export let classNameToEndpointInfo = new Map();
77 |
78 | export function getMethodsForService(className: string): EndpointInfo[] {
79 | let methods = getMethodParamsInfo(className);
80 | return methods;
81 | }
82 |
83 | function methodDecoratorSaveParameterTypes(
84 | options: EndpointOptions,
85 | target: any,
86 | key: string
87 | ) {
88 | const types = Reflect.getMetadata("design:paramtypes", target, key);
89 | const returnType = Reflect.getMetadata("design:returntype", target, key);
90 | const paramNames = methodDecoratorGetParamNames(target[key]);
91 |
92 | if (!classNameToEndpointInfo.has(target.constructor.name)) {
93 | classNameToEndpointInfo.set(target.constructor.name, []);
94 | }
95 | classNameToEndpointInfo.get(target.constructor.name).push({
96 | target: target.constructor,
97 | methodName: key,
98 | paramNames,
99 | paramTypes: types,
100 | returnType,
101 | options,
102 | });
103 | }
104 |
105 | export function getMethodParamsInfo(className: string) {
106 | return classNameToEndpointInfo.get(className);
107 | }
108 |
109 | function methodDecoratorGetParamNames(func: Function) {
110 | const funcStr = func
111 | .toString()
112 | .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm, "");
113 | const result = funcStr
114 | .slice(funcStr.indexOf("(") + 1, funcStr.indexOf(")"))
115 | .match(/([^\s,]+)/g);
116 | return result === null ? [] : result;
117 | }
118 |
119 | let devNull = (a, b) => {};
120 |
121 | // @todo types are not being extracted here unless method are decorated
122 | // with @Endpoint (or any decorator)
123 | function classDecoratorSaveParameterTypes(target: any) {
124 | const methods = Object.getOwnPropertyNames(target.prototype);
125 | for (const method of methods) {
126 | const descriptor = Object.getOwnPropertyDescriptor(
127 | target.prototype,
128 | method
129 | );
130 | if (!descriptor || typeof descriptor.value !== "function") {
131 | continue;
132 | }
133 | const types = Reflect.getMetadata(
134 | "design:paramtypes",
135 | target.prototype,
136 | method
137 | );
138 | const paramNames = classDecoratorGetParamNames(descriptor.value);
139 | devNull(types, paramNames);
140 | }
141 | }
142 |
143 | function classDecoratorGetParamNames(func: Function) {
144 | const funcStr = func
145 | .toString()
146 | .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm, "");
147 | const result = funcStr
148 | .slice(funcStr.indexOf("(") + 1, funcStr.indexOf(")"))
149 | .match(/([^\s,]+)/g);
150 | return result === null ? [] : result;
151 | }
152 |
153 | let unexposedMethods = new Set();
154 |
155 | /**
156 | * Class methods decorated with the `@Unexposed()` decorator will not be
157 | * exposed as HTTP endpoints.
158 | */
159 | export function Unexposed() {
160 | return function (
161 | target: any,
162 | propertyKey: string,
163 | descriptor: PropertyDescriptor
164 | ) {
165 | unexposedMethods.add(`${target.constructor.name}.${propertyKey}`);
166 | };
167 | }
168 |
169 | export function isUnexposed(_class: any, methodName: string): boolean {
170 | return unexposedMethods.has(`${_class.name}.${methodName}`);
171 | }
172 |
173 | let rawMethods = new Set();
174 |
175 | /**
176 | * Methods annotated with the `@Raw()` decorator will be registered as
177 | * direct HTTP methods and have access to HTTP request and response types.
178 | * A prime example of this is a file upload endpoint.
179 | *
180 | * Methods that are not annotated with `@Raw()` will only have access to their JSON request data.
181 | * That is favorable to raw HTTP methods as it is cleaner, enables testing etc.
182 | */
183 | export function Raw() {
184 | return function (
185 | target: any,
186 | propertyKey: string,
187 | descriptor: PropertyDescriptor
188 | ) {
189 | rawMethods.add(`${target.constructor.name}.${propertyKey}`);
190 | };
191 | }
192 |
193 | export function isRaw(_class: any, methodName: string): boolean {
194 | return rawMethods.has(`${_class.name}.${methodName}`);
195 | }
196 |
197 | /** Returns the argument types of a class type as a string slice
198 | * make sure the type you pass into this has been decorated with a decorator
199 | * otherwise Reflect can't inspec its types (see comments at the top of this package).
200 | */
201 | export function inputParamTypeNames(t: any): string[] {
202 | return inputParamTypes(t).map((t) => t.name);
203 | }
204 |
205 | export function inputParamTypes(t: any): any[] {
206 | let types = Reflect.getMetadata("design:paramtypes", t);
207 | if (!types) {
208 | return [];
209 | }
210 | // @todo why this filter is needed I'm not sure
211 | // there is likely a hidden bug here. investigate.
212 | return types.filter((t) => t !== undefined);
213 | }
214 |
215 | export function getMeta(serviceClass: any): ServiceMeta {
216 | let instance = new serviceClass();
217 | return instance.meta;
218 | }
219 |
220 | /**
221 | * Recursively returns all dependencies of a class (ie. also
222 | * dependencies of dependencies), flattened into a single array.
223 | *
224 | * @param serviceClass the class to get the dependencies of
225 | * @returns a list of service classes
226 | */
227 | export function getDependencyGraph(_class: any): any[] {
228 | let deps = inputParamTypes(_class);
229 | deps.forEach((d) => deps.push(getDependencyGraph(d)));
230 | return _.uniqBy(_.flatten(deps), (d) => d.name);
231 | }
232 |
--------------------------------------------------------------------------------
/src/registrator.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@jest/globals";
2 |
3 | import { Registrator } from "./registrator.js";
4 | import express from "express";
5 | import { Service, Unexposed, Raw } from "./reflect.js";
6 | import { default as request } from "supertest";
7 | import { error } from "./util.js";
8 |
9 | @Service()
10 | class A1 {
11 | meta = {
12 | name: "aone",
13 | };
14 | constructor() {}
15 |
16 | async a1() {
17 | return { hi: "a1" };
18 | }
19 | }
20 |
21 | @Service()
22 | class A2Service {
23 | constructor() {}
24 |
25 | async a2() {
26 | return { hi: "a2" };
27 | }
28 | }
29 |
30 | test("test meta name", async () => {
31 | const app = express();
32 | let reg = new Registrator(app);
33 | reg.register([A1, A2Service]);
34 |
35 | let response = await request(app).post("/aone/a1").send({});
36 | expect(response.status).toBe(200);
37 | expect(response.body).toEqual({ hi: "a1" });
38 |
39 | // test fallback
40 |
41 | response = await request(app).post("/A1/a1").send({});
42 | expect(response.status).toBe(200);
43 | expect(response.body).toEqual({ hi: "a1" });
44 |
45 | response = await request(app).post("/A2/a2").send({});
46 | expect(response.status).toBe(200);
47 | expect(response.body).toEqual({ hi: "a2" });
48 | });
49 |
50 | @Service()
51 | class A {
52 | constructor() {}
53 |
54 | async a() {
55 | return { hi: 5 };
56 | }
57 |
58 | @Unexposed()
59 | async b() {
60 | return { hi: 5 + 1 };
61 | }
62 | }
63 |
64 | test("test unexposed", async () => {
65 | const app = express();
66 | let reg = new Registrator(app);
67 | reg.register([A]);
68 |
69 | let response = await request(app).post("/A/a").send({});
70 | expect(response.status).toBe(200);
71 | expect(response.body).toEqual({ hi: 5 });
72 |
73 | response = await request(app).post("/A/b").send({});
74 | expect(response.status).toBe(404);
75 | });
76 |
77 | @Service()
78 | class B {
79 | constructor() {}
80 |
81 | async a() {
82 | return { hi: 5 };
83 | }
84 |
85 | @Raw()
86 | async b(req: express.Request, res: express.Response) {
87 | res.send(JSON.stringify({ hi: req.headers["hi"] })).end();
88 | }
89 | }
90 |
91 | test("test raw", async () => {
92 | const app = express();
93 | let reg = new Registrator(app);
94 | reg.register([B]);
95 |
96 | let response = await await request(app)
97 | .post("/B/b")
98 | .set({ hi: "hello" })
99 | .send();
100 | expect(response.status).toBe(200);
101 | expect(response.body).toEqual({ hi: "hello" });
102 | });
103 |
104 | @Service()
105 | class C {
106 | constructor() {}
107 |
108 | async a() {
109 | throw error("oh noes");
110 | }
111 |
112 | async b() {
113 | let a;
114 | // trigger an undefined error
115 | console.log(a.x);
116 | }
117 | }
118 |
119 | test("test errors", async () => {
120 | const app = express();
121 | let reg = new Registrator(app);
122 | reg.register([C]);
123 |
124 | let response = await await request(app).post("/C/a").send({ hi: "hello" });
125 | expect(response.status).toBe(500);
126 | expect(response.body).toEqual({ error: "oh noes" });
127 |
128 | response = await await request(app).post("/C/b").send({ hi: "hello" });
129 | expect(response.status).toBe(500);
130 | expect(
131 | JSON.stringify(response.body).includes(
132 | "TypeError: Cannot read properties of undefined (reading 'x')"
133 | )
134 | ).toEqual(true);
135 | });
136 |
--------------------------------------------------------------------------------
/src/service/admin/index.ts:
--------------------------------------------------------------------------------
1 | import teardown from "./teardown.js";
2 |
3 | import { TeardownRequest } from "./models.js";
4 |
5 | import { DataSource } from "typeorm";
6 | import { Service } from "../../reflect.js";
7 | import { Servicelike } from "../../util.js";
8 |
9 | @Service()
10 | export class AdminService implements Servicelike {
11 | private connection: DataSource;
12 |
13 | constructor(connection: DataSource) {
14 | this.connection = connection;
15 | }
16 |
17 | meta = {
18 | name: "admin",
19 | typeorm: {
20 | entities: [File],
21 | },
22 | };
23 |
24 | teardown(req: TeardownRequest) {
25 | return teardown(this.connection, req);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/service/admin/models.ts:
--------------------------------------------------------------------------------
1 | import { Entity, PrimaryColumn } from "typeorm";
2 |
3 | @Entity()
4 | // A recurrence is a rule to make something recurring every X interval
5 | export class Recurrence {
6 | @PrimaryColumn()
7 | id?: string;
8 |
9 | // cron expression
10 | // eg. "45 23 * * 6" 23:45 means (11:45 PM) every Saturday
11 | cron?: string;
12 | }
13 |
14 | export interface TeardownRequest {
15 | token: string;
16 | passphrase: string;
17 | }
18 |
19 | export interface TeardownResponse {}
20 |
--------------------------------------------------------------------------------
/src/service/admin/teardown.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { TeardownRequest, TeardownResponse } from "./models.js";
3 | import { Token, roleAdmin } from "../authentication/models.js";
4 | import { error } from "../../util.js";
5 | import { default as env } from "../../env.js";
6 |
7 | export default async (
8 | connection: DataSource,
9 | req: TeardownRequest
10 | ): Promise => {
11 | if (env.isProd) {
12 | throw error("can not tear down production", 400);
13 | }
14 | let token: Token = await connection
15 | .createQueryBuilder(Token, "token")
16 | .where("token.token = :id", { id: req.token })
17 | .leftJoinAndSelect("token.user", "user")
18 | .leftJoinAndSelect("user.roles", "role")
19 | .getOne();
20 | if (!token) {
21 | throw error("token not found", 400);
22 | }
23 |
24 | let isAdmin = false;
25 | if (token.user.roles?.find((r) => r.id == roleAdmin.id)) {
26 | isAdmin = true;
27 | }
28 | if (!isAdmin) {
29 | throw error("not authorized", 400);
30 | }
31 | if (req.passphrase != "i-really-do-want-to-tear-it-all-down") {
32 | throw error("not authorized", 400);
33 | }
34 | if (connection.name == "deflt") {
35 | throw error("can not drop default", 400);
36 | }
37 |
38 | await connection.dropDatabase();
39 | await connection.synchronize();
40 | return {};
41 | };
42 |
--------------------------------------------------------------------------------
/src/service/authentication/README.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](../../../docs/README.md)
2 | # Authentication service
3 |
4 | ```ts
5 | import { Service, AuthenticationService } from "@crufters/actio"
6 |
7 | @Service()
8 | class MyService {
9 | constructor(auth: AuthenticationService) {...}
10 | }
11 | ```
12 |
13 | - [Authentication service](#authentication-service)
14 | - [Basics](#basics)
15 | - [Concepts](#concepts)
16 | - [Contacts](#contacts)
17 | - [Organizations and departments](#organizations-and-departments)
18 | - [Roles](#roles)
19 | - [Oauth](#oauth)
20 | - [Facebook oauth setup](#facebook-oauth-setup)
21 | - [Facebook oauth troubleshooting](#facebook-oauth-troubleshooting)
22 | - [Envars](#envars)
23 |
24 | ## Basics
25 |
26 | The basic usage of the auth service involves registration
27 |
28 | ```ts
29 | let resp = await auth.userRegister({
30 | user: {
31 | contacts: [
32 | {
33 | url: "user@test.com",
34 | },
35 | ],
36 | },
37 | password: "123",
38 | });
39 | ```
40 |
41 | and reading token information:
42 |
43 | ```ts
44 | let trsp = await auth.tokenRead({ token: req.token });
45 | console.log(trsp.token.user?.fullName)
46 | ```
47 |
48 | ## Concepts
49 |
50 | ### Contacts
51 |
52 | Looking at the models of the this service you might spot that the `User` class has no email field. The reason for this is that the Auth service is designed so different way of authenticating, such proving access to phones, or a Facebook account etc. is equivalent to an email registration.
53 |
54 | This means that you can build a system where people register with phones and nothing else for example.
55 |
56 | ## Organizations and departments
57 |
58 | In most systems there is a need for `Organization` accounts. While most users will have no `Organization`s, your business clients for example will probably want multiple people accessing your software.
59 |
60 | Looking at the `User` class it can be seen that users belong to `Department`s. `Department`s are simply a way to further divide `Organization`s into section. This is mostly useful for larger systems so they get created automatically when a user creates an organization:
61 |
62 | ```ts
63 | let resp = await auth.userRegister({
64 | user: {
65 | contacts: [
66 | {
67 | url: "user@test.com",
68 | },
69 | ],
70 | },
71 | password: "123",
72 | });
73 |
74 | await auth.userCreateOrganization({
75 | token: resp.token.token,
76 | organization: {
77 | name: "Test Org",
78 | },
79 | });
80 | ```
81 |
82 | Once the user creates their Organization an organization and a department is created for them and they get assigned the following roles:
83 |
84 | ```
85 | organization:$orgId:admin
86 | department:$departmentId:admin
87 | ```
88 |
89 | ## Roles
90 |
91 | There are no dictated roles in Actio, but a typical application will have the following roles:
92 |
93 | - admin: this is the site admin, ie. you
94 | - organization users: either all users for b2b apps, or your paying advertisers or similar in a b2c app
95 | - a normal user without any roles to start out
96 |
97 | ## Oauth
98 |
99 | ### Facebook oauth setup
100 |
101 | Based on https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow
102 |
103 | https://medium.com/@jackrobertscott/facebook-auth-with-node-js-c4bb90d03fc0
104 |
105 | https://stackoverflow.com/questions/13376710/facebook-app-domain-name-when-using-localhost
106 |
107 | Include the facebook app id in the `AuthenticationService` config or add `AUTHENTICATION_FACEBOOK_APP_ID` and `AUTHENTICATION_FACEBOOK_APP_SECRET` to your `.env`.
108 |
109 |
110 | #### Facebook oauth troubleshooting
111 |
112 | - It seems like one needs to use the `Facebook Login` app and not the `Facebook Login with Business` app because the latter does not work.
113 |
114 | - If you don't get an email back, look at the permissions of the app
115 |
116 | - For local development create a test app of your Facebook app.
117 |
118 | ## Envars
119 |
120 | ```sh
121 | # default admin email address that gets registered on onInit
122 | # if there are no admin users
123 | AUTHENTICATION_ADMIN_EMAIL=...
124 |
125 | # default admin password
126 | AUTHENTICATION_ADMIN_PASSWORD=...
127 |
128 | # default admin organization name
129 | AUTHENTICATION_ADMIN_ORGANIZATION=...
130 |
131 | # default admin full name
132 | AUTHENTICATION_ADMIN_FULLNAME=...
133 |
134 | # facebook oauth app id
135 | AUTHENTICATION_FACEBOOK_APP_ID=...
136 |
137 | # facebook oauth app secret
138 | AUTHENTICATION_FACEBOOK_APP_SECRET=...
139 |
140 | # facebook oauth redirect
141 | AUTHENTICATION_FACEBOOK_APP_REDIRECT_URL=...
142 | ```
143 |
144 |
--------------------------------------------------------------------------------
/src/service/authentication/department/departmentList.ts:
--------------------------------------------------------------------------------
1 | import { DataSource, SelectQueryBuilder } from "typeorm";
2 | import {
3 | User,
4 | Department,
5 | Token,
6 | DepartmentListRequest,
7 | DepartmentListResponse,
8 | roleAdmin,
9 | } from "../models.js";
10 | import { error } from "../../../util.js";
11 |
12 | export default async (
13 | connection: DataSource,
14 | req: DepartmentListRequest
15 | ): Promise => {
16 | let token: Token = await connection
17 | .createQueryBuilder(Token, "token")
18 | .where("token.token = :id", { id: req.token })
19 | .getOne();
20 | if (!token) {
21 | throw error("token not found", 400);
22 | }
23 |
24 | let user: User = await connection
25 | .createQueryBuilder(User, "user")
26 | .where("user.id = :id", { id: token.userId })
27 | .leftJoinAndSelect("user.roles", "roles")
28 | .getOne();
29 |
30 | let isAdmin = false;
31 | if (user.roles?.find((r) => r.id == roleAdmin.id)) {
32 | isAdmin = true;
33 | }
34 | // @todo check if user has access to department
35 |
36 | if (isAdmin && false) {
37 | let departments: Department[] = await join(
38 | connection
39 | .createQueryBuilder(Department, "department")
40 | .orderBy("department.createdAt", "DESC")
41 | .leftJoinAndSelect("department.users", "user")
42 | ).getMany();
43 |
44 | return {
45 | departments: departments,
46 | };
47 | }
48 | if (!isAdmin) {
49 | throw error("no access", 400);
50 | }
51 |
52 | let departments: Department[] = await connection
53 | .createQueryBuilder(Department, "department")
54 | .orderBy("department.createdAt", "DESC")
55 | .innerJoinAndSelect("department.users", "user", "user.id = :userId", {
56 | userId: user.id,
57 | })
58 | .getMany();
59 |
60 | return {
61 | departments: departments,
62 | };
63 | };
64 |
65 | export function join(
66 | s: SelectQueryBuilder
67 | ): SelectQueryBuilder {
68 | return s.leftJoinAndSelect("department.users", "user");
69 | }
70 |
--------------------------------------------------------------------------------
/src/service/authentication/oauth/facebookLogin.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { FacebookLoginRequest, FacebookLoginResponse } from "../models.js";
3 | import registerOrLoginWithProvenIdentity from "./registerOrLoginWithProvenIdentity.js";
4 | import axios from "axios";
5 |
6 | interface MeResponse {
7 | id: string;
8 | email: string;
9 | first_name: string;
10 | last_name: string;
11 | }
12 |
13 | async function getFacebookUserData(access_token): Promise {
14 | const { data } = await axios({
15 | url: "https://graph.facebook.com/me",
16 | method: "get",
17 | params: {
18 | // @todo make this configurable?
19 | // test response https://developers.facebook.com/tools/explorer/
20 | fields: ["id", "email", "first_name", "last_name"].join(","),
21 | access_token: access_token,
22 | },
23 | });
24 | return data;
25 | }
26 |
27 | // https://medium.com/@jackrobertscott/facebook-auth-with-node-js-c4bb90d03fc0
28 | async function getAccessTokenFromCode(code, appId, appSecret, appRedirect) {
29 | const { data } = await axios({
30 | url: "https://graph.facebook.com/v15.0/oauth/access_token",
31 | method: "get",
32 | params: {
33 | client_id: appId,
34 | client_secret: appSecret,
35 | redirect_uri: appRedirect,
36 | code,
37 | },
38 | });
39 | // data example: { access_token, token_type, expires_in }
40 | // {
41 | // access_token: 'EAAItvar5EZA...GAI1XOujNby9yVL7iyYEG4jEwzw1bJQZDZD',
42 | // token_type: 'bearer',
43 | // expires_in: 5178870
44 | // }
45 |
46 | return data.access_token;
47 | }
48 |
49 | export default async (
50 | connection: DataSource,
51 | request: FacebookLoginRequest,
52 | facebookAppID: string,
53 | facebookAppSecret: string,
54 | facebookAppRedirectURL: string
55 | ): Promise => {
56 | let accessToken = await getAccessTokenFromCode(
57 | request.accessToken,
58 | facebookAppID,
59 | facebookAppSecret,
60 | facebookAppRedirectURL
61 | );
62 | let rsp = await getFacebookUserData(accessToken);
63 | // @todo we could save the facebook access token but
64 | // we don't really need it apart from login or register
65 | return await registerOrLoginWithProvenIdentity(connection, {
66 | firstName: rsp.first_name,
67 | lastName: rsp.last_name,
68 | email: rsp.email,
69 | });
70 | };
71 |
--------------------------------------------------------------------------------
/src/service/authentication/oauth/oauthInfo.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { OauthInfoRequest, OauthInfoResponse } from "../models.js";
3 |
4 | export default async (
5 | connection: DataSource,
6 | request: OauthInfoRequest,
7 | facebookAppID?: string
8 | ): Promise => {
9 | return {
10 | facebookAppID: facebookAppID,
11 | };
12 | };
13 |
--------------------------------------------------------------------------------
/src/service/authentication/oauth/registerOrLoginWithProvenIdentity.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import {
3 | Contact,
4 | Token,
5 | User,
6 | platformEmail,
7 | RegisterOrLoginWithProvenIdentityRequest,
8 | RegisterOrLoginWithProvenIdentityResponse,
9 | } from "../models.js";
10 | import { nanoid } from "nanoid";
11 |
12 | // can be used for facebook and all oauth registrations after the
13 | // identity is proven
14 | export default async (
15 | connection: DataSource,
16 | req: RegisterOrLoginWithProvenIdentityRequest
17 | ): Promise => {
18 | let contacts = await connection
19 | .createQueryBuilder(Contact, "contact")
20 | .where("contact.url = :url", { url: req.email })
21 | .leftJoinAndSelect("contact.user", "user")
22 | .getMany();
23 |
24 | // user is already registered
25 | if (contacts.length > 0) {
26 | let token = new Token();
27 | token.id = nanoid();
28 | token.token = nanoid();
29 | token.userId = contacts[0].user.id;
30 |
31 | await connection.getRepository(Token).save(token);
32 |
33 | return {
34 | token: token,
35 | };
36 | }
37 |
38 | let userId = nanoid();
39 | let user = new User();
40 | user.fullName = req.firstName + " " + req.lastName;
41 | // @todo
42 | user.slug = nanoid();
43 |
44 | let contact = new Contact();
45 | let contactId = nanoid();
46 | contact.id = contactId;
47 | contact.platformId = platformEmail.id;
48 | contact.url = req.email;
49 | contact.verified = true;
50 | contact.userId = userId;
51 |
52 | let token = new Token();
53 | token.id = nanoid();
54 | token.userId = userId;
55 | token.token = nanoid();
56 |
57 | await connection.transaction(async (tran) => {
58 | // roles will be saved (created even)
59 | // due to the cascade option, see the model
60 | await tran.save(user);
61 | await tran.save(contact);
62 | await tran.save(token);
63 | });
64 |
65 | return {
66 | token: token,
67 | };
68 | };
69 |
--------------------------------------------------------------------------------
/src/service/authentication/platform/platformList.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { Platform, PlatformListResponse } from "../models.js";
3 |
4 | export default async (
5 | connection: DataSource,
6 | request: any
7 | ): Promise => {
8 | let platforms = await connection.getRepository(Platform).find({});
9 |
10 | return {
11 | platforms: platforms,
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/src/service/authentication/role/roleList.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { Role, RoleListResponse } from "../models.js";
3 |
4 | export default async (
5 | connection: DataSource,
6 | request: any
7 | ): Promise => {
8 | let roles = await connection.getRepository(Role).find({});
9 |
10 | return {
11 | roles: roles,
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/src/service/authentication/token/tokenRead.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../../util.js";
3 | import { Token, TokenReadRequest, TokenReadResponse } from "../models.js";
4 |
5 | export default async (
6 | connection: DataSource,
7 | req: TokenReadRequest
8 | ): Promise => {
9 | let token: Token = await connection
10 | .createQueryBuilder(Token, "token")
11 | .where("token.token = :id", { id: req.token })
12 | .leftJoinAndSelect("token.user", "user")
13 | .leftJoinAndSelect("user.roles", "role")
14 | .leftJoinAndSelect("user.contacts", "contact")
15 | .leftJoinAndSelect("contact.platform", "platform")
16 | //.leftJoinAndSelect("user.thumbnail", "thumbnail")
17 | .leftJoinAndSelect("user.departments", "department")
18 | .leftJoinAndSelect("department.organization", "organization")
19 | //.leftJoinAndSelect("organization.thumbnail", "orgthumbnail")
20 | .getOne();
21 | if (!token) {
22 | throw error("token not found", 400);
23 | }
24 |
25 | return {
26 | token: token,
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/src/service/authentication/user/passwordChange.ts:
--------------------------------------------------------------------------------
1 | import * as bcrypt from "bcrypt";
2 | import { nanoid } from "nanoid";
3 | import { DataSource } from "typeorm";
4 | import { error } from "../../../util.js";
5 | import {
6 | Password,
7 | PasswordChangeRequest,
8 | PasswordChangeResponse,
9 | SecretCode,
10 | Token,
11 | User,
12 | } from "../models.js";
13 |
14 | export default async (
15 | connection: DataSource,
16 | request: PasswordChangeRequest
17 | ): Promise => {
18 | let code: SecretCode = await connection
19 | .createQueryBuilder(SecretCode, "code")
20 | .where("code.code = :id", { id: request.code })
21 | .getOne();
22 |
23 | if (!code || !code.id) {
24 | throw error("code not found", 400);
25 | }
26 |
27 | if (code.used) {
28 | throw error("code already used", 400);
29 | }
30 |
31 | let user: User = await connection
32 | .createQueryBuilder(User, "user")
33 | .where("user.id = :id", { id: code.userId })
34 | .leftJoinAndSelect("user.roles", "role")
35 | .leftJoinAndSelect("user.contacts", "contact")
36 | .getOne();
37 |
38 | let password = new Password();
39 | password.id = await bcrypt.hash(request.newPassword, 10);
40 | password.userId = user.id;
41 |
42 | let token = new Token();
43 | token.id = nanoid();
44 | token.userId = user.id;
45 | token.token = nanoid();
46 |
47 | await connection.transaction(async (tran) => {
48 | // @todo do not delete all old passwords
49 | await tran
50 | .createQueryBuilder(SecretCode, "secret_code")
51 | .update(SecretCode)
52 | .where(`secret_code.id = :codeId`, {
53 | codeId: code.id,
54 | })
55 | .set({
56 | used: true,
57 | })
58 | .execute();
59 |
60 | await tran
61 | .createQueryBuilder(Password, "password")
62 | .delete()
63 | .where(`password."userId" = :userId`, {
64 | userId: user.id,
65 | })
66 | .execute();
67 |
68 | await tran.save(password);
69 | await tran.save(token);
70 | });
71 |
72 | return { token: token };
73 | };
74 |
--------------------------------------------------------------------------------
/src/service/authentication/user/passwordChangeWithOld.ts:
--------------------------------------------------------------------------------
1 | import * as bcrypt from "bcrypt";
2 | import { DataSource } from "typeorm";
3 | import { error } from "../../../util.js";
4 | import {
5 | Contact,
6 | Password,
7 | PasswordChangeWithOldRequest,
8 | PasswordChangeWithOldResponse,
9 | platformEmail,
10 | } from "../models.js";
11 |
12 | const err = error("incorrect credentials", 400);
13 |
14 | export default async (
15 | connection: DataSource,
16 | request: PasswordChangeWithOldRequest
17 | ): Promise => {
18 | // only email login supported for now
19 | let contacts = await connection
20 | .createQueryBuilder(Contact, "contact")
21 | .where("contact.url = :url AND contact.platformId = :platformId", {
22 | url: request.contactUrl,
23 | platformId: platformEmail.id,
24 | })
25 | .leftJoinAndSelect("contact.user", "user")
26 | .getMany();
27 |
28 | if (!contacts || contacts.length == 0) {
29 | return err;
30 | throw error("contact not found", 400);
31 | }
32 | let contact = contacts[0];
33 | if (!contact.user) {
34 | return err;
35 | throw error("user for contact not found", 400);
36 | }
37 | if (!contact.user.id) {
38 | return err;
39 | }
40 |
41 | let passwords = await connection
42 | .createQueryBuilder(Password, "password")
43 | .where("password.userId = :id", {
44 | id: contact.user.id,
45 | })
46 | .getMany();
47 | if (!passwords || passwords.length == 0) {
48 | return err;
49 | throw error("password not found", 400);
50 | }
51 | // @todo be very careful with filter and other list ops when supporting
52 | // multiple passwords as most ops don't support promises
53 | let matched = await bcrypt.compare(request.oldPassword, passwords[0].id);
54 |
55 | if (!matched) {
56 | return err;
57 | throw error("login unsuccessful", 400);
58 | }
59 |
60 | let password = new Password();
61 | password.id = await bcrypt.hash(request.newPassword, 10);
62 | password.userId = contact.user.id;
63 |
64 | await connection.transaction(async (tran) => {
65 | // @todo do not delete all old passwords
66 | await tran
67 | .createQueryBuilder(Password, "password")
68 | .delete()
69 | .where(`password."userId" = :userId`, {
70 | userId: contact.user.id,
71 | })
72 | .execute();
73 |
74 | await tran.save(password);
75 | });
76 |
77 | return {};
78 | };
79 |
--------------------------------------------------------------------------------
/src/service/authentication/user/passwordSendReset.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import {
3 | PasswordSendResetRequest,
4 | PasswordSendResetResponse,
5 | User,
6 | SecretCode,
7 | } from "../models.js";
8 | import sendgrid from "@sendgrid/mail";
9 | import { Error, error } from "../../../util.js";
10 | import { nanoid } from "nanoid";
11 | import { default as configRead } from "../../config/configRead.js";
12 |
13 | export default async (
14 | connection: DataSource,
15 | request: PasswordSendResetRequest
16 | ): Promise => {
17 | let user: User = await connection
18 | .createQueryBuilder(User, "user")
19 | .innerJoinAndSelect(
20 | "user.contacts",
21 | "contact",
22 | "contact.url = :contactUrl",
23 | { contactUrl: request.contactUrl }
24 | )
25 | .leftJoinAndSelect("user.roles", "role")
26 | .getOne();
27 | if (!user || !user.id) {
28 | throw error("not found", 400);
29 | }
30 |
31 | let code = new SecretCode();
32 | code.id = nanoid();
33 | code.code = nanoid();
34 | code.userId = user.id;
35 |
36 | let c = await configRead(connection, {});
37 | if (c instanceof Error) {
38 | return c;
39 | }
40 |
41 | // @todo(janos) get sendgrid key
42 | sendgrid.setApiKey("get sendgrid key");
43 | const msg: sendgrid.MailDataRequired = {
44 | // @todo this only supports a single contract
45 | to: user.contacts[0].url, // Change to your recipient
46 | from: c.config.contact.email, // Change to your verified sender
47 | subject: "Jelszó csere",
48 | text:
49 | "Szia,\n\nJelszó cserét kértél. Ezen a linken megteheted:\n\nhttps://" +
50 | c.config.domain +
51 | "/auth/password-reset?code=" +
52 | code.code +
53 | "\n\nHa mégsem te voltál, akkor hagyd figyelmen kívül ezt az emailt.\n\nA " +
54 | c.config.og.siteName +
55 | " csapata",
56 | };
57 |
58 | await connection.transaction(async (tran) => {
59 | await tran.save(code);
60 | });
61 |
62 | await sendgrid.send(msg);
63 |
64 | return {};
65 | };
66 |
--------------------------------------------------------------------------------
/src/service/authentication/user/tokenAdminGet.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import {
3 | roleAdmin,
4 | Token,
5 | TokenAdminGetRequest,
6 | TokenAdminGetResponse,
7 | } from "../models.js";
8 |
9 | export default async (
10 | connection: DataSource,
11 | request: TokenAdminGetRequest
12 | ): Promise => {
13 | let token: Token = await connection
14 | .createQueryBuilder(Token, "token")
15 | .innerJoinAndSelect("token.user", "user")
16 | .innerJoinAndSelect("user.roles", "role", "role.id = :id", {
17 | id: roleAdmin.id,
18 | })
19 | .getOne();
20 |
21 | return {
22 | token: token,
23 | };
24 | };
25 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userCreateOrganization.ts:
--------------------------------------------------------------------------------
1 | import { nanoid } from "nanoid";
2 | import slug from "slug";
3 | import { DataSource } from "typeorm";
4 | import { error } from "../../../util.js";
5 | import {
6 | Department,
7 | Organization,
8 | Token,
9 | UserCreateOrganizationRequest,
10 | UserCreateOrganizationResponse,
11 | } from "../models.js";
12 |
13 | export default async (
14 | connection: DataSource,
15 | request: UserCreateOrganizationRequest
16 | ): Promise => {
17 | if (!request.organization) {
18 | throw error("missing organization", 400);
19 | }
20 | let token: Token = await connection
21 | .createQueryBuilder(Token, "token")
22 | .where("token.token = :id", { id: request.token })
23 | .leftJoinAndSelect("token.user", "user")
24 | .leftJoinAndSelect("user.roles", "role")
25 | .leftJoinAndSelect("user.departments", "department")
26 | .getOne();
27 | if (!token) {
28 | throw error("token not found", 400);
29 | }
30 |
31 | // check if organization exists
32 | let existingOrganization = await connection
33 | .createQueryBuilder(Organization, "organization")
34 | .where("organization.name = :name", { name: request.organization.name })
35 | .leftJoinAndSelect("organization.departments", "department")
36 | .getOne();
37 | if (
38 | existingOrganization &&
39 | existingOrganization.departments.length > 0 &&
40 | existingOrganization.departments.find((d) =>
41 | token.user.departments.find((ud) => ud.id == d.id)
42 | )
43 | ) {
44 | return {
45 | organization: existingOrganization,
46 | };
47 | }
48 |
49 | let organization = new Organization();
50 | organization.id = nanoid();
51 | organization.name = request.organization.name;
52 | organization.slug = slug(request.organization.name);
53 |
54 | // create the first department
55 | let department = new Department();
56 | department.id = nanoid();
57 | department.name = "Department #1";
58 | department.organizationId = organization.id;
59 | department.slug = slug(department.name);
60 | department.balance = 0;
61 |
62 | let user = token.user;
63 | user.departments = [department];
64 | user.roles.push(
65 | {
66 | id: nanoid(),
67 | key: `organization:${organization.id}:admin`,
68 | },
69 | { id: nanoid(), key: `department:${department.id}:admin` }
70 | );
71 |
72 | await connection.transaction(async (tran) => {
73 | // roles will be saved (created even)
74 | // due to the cascade option, see the model
75 | await tran.save(organization);
76 | await tran.save(department);
77 | await tran.save(user);
78 | });
79 |
80 | return {
81 | organization: organization,
82 | };
83 | };
84 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userList.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../../util.js";
3 | import {
4 | roleAdmin,
5 | Token,
6 | User,
7 | UserListRequest,
8 | UserListResponse,
9 | } from "../models.js";
10 |
11 | export default async (
12 | connection: DataSource,
13 | request: UserListRequest
14 | ): Promise => {
15 | let token: Token = await connection
16 | .createQueryBuilder(Token, "token")
17 | .where("token.token = :id", { id: request.token })
18 | .getOne();
19 | if (!token) {
20 | throw error("token not found", 400);
21 | }
22 |
23 | let user: User = await connection
24 | .createQueryBuilder(User, "user")
25 | .where("user.id = :id", { id: token.userId })
26 | .leftJoinAndSelect("user.roles", "role")
27 | .leftJoinAndSelect("user.departments", "department")
28 | .getOne();
29 |
30 | let isAdmin = false;
31 | if (user.roles?.find((r) => r.id == roleAdmin.id)) {
32 | isAdmin = true;
33 | }
34 |
35 | if (!isAdmin) {
36 | throw error("no rights", 400);
37 | }
38 |
39 | if (!request.orderByField) {
40 | request.orderByField = "createdAt";
41 | }
42 | if (!request.skip) {
43 | request.skip = 0;
44 | }
45 | if (!request.limit) {
46 | request.limit = 100;
47 | }
48 |
49 | if (
50 | request.departmentId &&
51 | !user.departments.find((d) => {
52 | return (d.id = request.departmentId);
53 | })
54 | ) {
55 | throw error("no access to department", 400);
56 | }
57 |
58 | if (request.departmentId) {
59 | let users: User[] = await connection
60 | .createQueryBuilder(User, "user")
61 | .orderBy("user." + request.orderByField, request.asc ? "ASC" : "DESC")
62 | .skip(request.skip)
63 | .limit(request.limit)
64 | .innerJoinAndSelect(
65 | "user.departments",
66 | "department",
67 | "department.id = :departmentId",
68 | { departmentId: request.departmentId }
69 | )
70 | .leftJoinAndSelect("user.roles", "role")
71 | .leftJoinAndSelect("user.contacts", "contact")
72 | //.leftJoinAndSelect("user.thumbnail", "thumbnail")
73 | .leftJoinAndSelect("department.organization", "organization")
74 | //.leftJoinAndSelect("organization.thumbnail", "orgthumbnail")
75 | .getMany();
76 |
77 | return {
78 | users: users,
79 | };
80 | }
81 |
82 | let users: User[] = await connection
83 | .createQueryBuilder(User, "user")
84 | .orderBy("user." + request.orderByField, request.asc ? "ASC" : "DESC")
85 | .skip(request.skip)
86 | .limit(request.limit)
87 | .leftJoinAndSelect("user.roles", "role")
88 | .leftJoinAndSelect("user.contacts", "contact")
89 | //.leftJoinAndSelect("user.thumbnail", "thumbnail")
90 | .leftJoinAndSelect("user.departments", "department")
91 | .leftJoinAndSelect("department.organization", "organization")
92 | //.leftJoinAndSelect("organization.thumbnail", "orgthumbnail")
93 | .getMany();
94 |
95 | return {
96 | users: users,
97 | };
98 | };
99 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userLogin.ts:
--------------------------------------------------------------------------------
1 | import * as bcrypt from "bcrypt";
2 | import { nanoid } from "nanoid";
3 | import { DataSource } from "typeorm";
4 | import { error } from "../../../util.js";
5 | import {
6 | Contact,
7 | Password,
8 | platformEmail,
9 | Token,
10 | UserLoginRequest,
11 | UserLoginResponse,
12 | } from "../models.js";
13 |
14 | const err = error("incorrect credentials", 400);
15 |
16 | export default async (
17 | connection: DataSource,
18 | request: UserLoginRequest
19 | ): Promise => {
20 | // only email login supported for now
21 | let contacts = await connection
22 | .createQueryBuilder(Contact, "contact")
23 | .where("contact.url = :url AND contact.platformId = :platformId", {
24 | url: request.contactUrl,
25 | platformId: platformEmail.id,
26 | })
27 | .leftJoinAndSelect("contact.user", "user")
28 | .getMany();
29 |
30 | if (!contacts || contacts.length == 0) {
31 | throw error("contact not found", 400);
32 | }
33 | let contact = contacts[0];
34 | if (!contact.user) {
35 | throw error("user for contact not found", 400);
36 | }
37 | if (!contact.user.id) {
38 | throw err;
39 | }
40 |
41 | let passwords = await connection
42 | .createQueryBuilder(Password, "password")
43 | .where("password.userId = :id", {
44 | id: contact.user.id,
45 | })
46 | .getMany();
47 | if (!passwords || passwords.length == 0) {
48 | throw error("password not found", 400);
49 | }
50 | // @todo be very careful with filter and other list ops when supporting
51 | // multiple passwords as most ops don't support promises
52 | let matched = await bcrypt.compare(request.password, passwords[0].id);
53 |
54 | if (!matched) {
55 | throw error("login unsuccessful", 400);
56 | }
57 | let token = new Token();
58 | // @todo Even the token should be hashed so even if the database leaks
59 | // token ID won't be usable
60 | // let unhashedTokenId = nanoid();
61 | // token.id = await bcrypt.hash(unhashedTokenId, 10);
62 | token.id = nanoid();
63 | token.token = nanoid();
64 | token.userId = contact.user.id;
65 |
66 | await connection.getRepository(Token).save(token);
67 |
68 | return {
69 | token: token,
70 | };
71 | };
72 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userRegister.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import {
3 | User,
4 | Contact,
5 | Token,
6 | Password,
7 | UserRegisterRequest,
8 | UserRegisterResponse,
9 | platformEmail,
10 | Secret,
11 | roleAdmin,
12 | } from "../models.js";
13 | import { nanoid } from "nanoid";
14 | import slug from "slug";
15 | import * as bcrypt from "bcrypt";
16 | import { error } from "../../../util.js";
17 | // import verificationCodeSend from "./verificationCodeSend";
18 | import userLogin from "./userLogin.js";
19 | import { ConfigService } from "../../config/index.js";
20 |
21 | export default async (
22 | connection: DataSource,
23 | config: ConfigService,
24 | request: UserRegisterRequest,
25 | defaultConfig: Secret
26 | ): Promise => {
27 | let userId = nanoid();
28 | let user = new User();
29 |
30 | let password = new Password();
31 | let contact = new Contact();
32 | let conf = await config.configRead({});
33 |
34 | if (!request.ghostRegister) {
35 | if (!request.password) {
36 | throw error("missing password", 400);
37 | }
38 |
39 | if (!request.user) {
40 | throw error("missing user", 400);
41 | }
42 |
43 | // check if user exists
44 | if (!request.user?.contacts) {
45 | throw error("missing contact", 400);
46 | }
47 |
48 | let cont = request.user.contacts[0];
49 |
50 | if (
51 | conf.config.isProduction &&
52 | cont.platformId == platformEmail.id &&
53 | cont.url.includes("+")
54 | ) {
55 | throw error(
56 | "email address cannot contain '+' in a production environment. " +
57 | "if this is a local environment set the config `isProduction` to false, because it is true for some reason. ",
58 | 400
59 | );
60 | }
61 |
62 | if (!isValidEmail(cont.url)) {
63 | throw error("invalid email address", 400);
64 | }
65 |
66 | let existingContacts: Contact[] = await connection
67 | .createQueryBuilder(Contact, "contact")
68 | .where(`contact."platformId" = :platformId AND contact.url = :url`, {
69 | platformId: platformEmail.id,
70 | url: cont.url,
71 | })
72 | .getMany();
73 |
74 | if (existingContacts?.length > 0) {
75 | // @todo maybe we should just throw an error here
76 | let rsp = await userLogin(connection, {
77 | contactUrl: cont.url,
78 | password: request.password,
79 | });
80 | return rsp;
81 | }
82 |
83 | let contactId = nanoid();
84 | contact.id = contactId;
85 | contact.platformId = platformEmail.id;
86 | contact.url = cont.url;
87 | contact.verified = false;
88 | contact.userId = userId;
89 |
90 | password.id = await bcrypt.hash(request.password, 10);
91 | password.userId = userId;
92 | } else {
93 | user.ghost = true;
94 | user.slug = "ghost-" + nanoid();
95 | }
96 |
97 | user.id = userId;
98 | if (request.user) {
99 | var slugable = request.user.slug || request.user.fullName || user.id;
100 | user.slug = slug(slugable);
101 | user.fullName = request.user.fullName;
102 | user.gender = request.user.gender;
103 | user.location = request.user.location;
104 | user.address = request.user.address;
105 | }
106 |
107 | let sc = await config.secretRead({});
108 | // hack
109 | if (
110 | (defaultConfig.adminPassword &&
111 | request.password == defaultConfig.adminPassword) ||
112 | (sc.secret?.data?.AuthenticationService?.adminPassword &&
113 | request.password == sc.secret.data?.AuthenticationService?.adminPassword)
114 | ) {
115 | user.roles = [roleAdmin];
116 | }
117 |
118 | let token = new Token();
119 | // @todo Even the token id will be hashed so even if the database leaks
120 | // token ID won't be usable
121 | // let unhashedTokenId = nanoid();
122 | // token.id = await bcrypt.hash(unhashedTokenId, 10);
123 | token.id = nanoid();
124 | token.userId = userId;
125 | token.token = nanoid();
126 |
127 | await connection.transaction(async (tran) => {
128 | // roles will be saved (created even)
129 | // due to the cascade option, see the model
130 | await tran.save(user);
131 | if (!request.ghostRegister) {
132 | await tran.save(password);
133 | await tran.save(contact);
134 | }
135 | await tran.save(token);
136 | });
137 |
138 | //if (!cont.url.includes(ownerDomain())) {
139 | // await verificationCodeSend(connection, config, {
140 | // token: token.token,
141 | // });
142 | //}
143 |
144 | return {
145 | token: token,
146 | };
147 | };
148 |
149 | const isValidEmail = (email) => {
150 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
151 | return emailRegex.test(email);
152 | };
153 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userSave.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../../util.js";
3 | import {
4 | roleAdmin,
5 | Token,
6 | User,
7 | UserSaveRequest,
8 | UserSaveResponse,
9 | } from "../models.js";
10 |
11 | export default async (
12 | connection: DataSource,
13 | request: UserSaveRequest
14 | ): Promise => {
15 | let token: Token = await connection
16 | .createQueryBuilder(Token, "token")
17 | .where("token.token = :id", { id: request.token })
18 | .getOne();
19 | if (!token) {
20 | throw error("token not found", 400);
21 | }
22 |
23 | let caller: User = await connection
24 | .createQueryBuilder(User, "user")
25 | .where("user.id = :id", { id: token.userId })
26 | .leftJoinAndSelect("user.roles", "role")
27 | .getOne();
28 |
29 | let user = new User(request.user);
30 | user.id = caller.id;
31 |
32 | let isAdmin = false;
33 | if (caller.roles?.find((r) => r.id == roleAdmin.id)) {
34 | isAdmin = true;
35 | }
36 |
37 | // @todo allow admins to update roles maybe?
38 | delete user.roles;
39 |
40 | // only allow admins to update others
41 | if (!isAdmin && caller.id != user.id) {
42 | throw error("not permitted", 400);
43 | }
44 |
45 | await connection.transaction(async (tran) => {
46 | if (user.meta) {
47 | let dbUser = await tran.findOne(User, {
48 | select: ["meta"],
49 | where: { id: user.id },
50 | });
51 | if (dbUser && dbUser.meta) {
52 | user.meta = mergeObjects(user.meta, dbUser.meta);
53 | }
54 | }
55 |
56 | await tran.save(user);
57 | });
58 |
59 | return { user: user };
60 | };
61 |
62 | function mergeObjects(obj1, obj2) {
63 | if (typeof obj1 !== "object" || typeof obj2 !== "object") {
64 | // Return the non-object value if either input is not an object
65 | return obj2 !== undefined ? obj2 : obj1;
66 | }
67 |
68 | var mergedObj = {};
69 |
70 | // Merge keys from obj1
71 | for (var key in obj1) {
72 | if (obj1.hasOwnProperty(key)) {
73 | mergedObj[key] = mergeObjects(obj1[key], obj2[key]);
74 | }
75 | }
76 |
77 | // Merge keys from obj2
78 | for (var key in obj2) {
79 | if (obj2.hasOwnProperty(key) && !obj1.hasOwnProperty(key)) {
80 | mergedObj[key] = mergeObjects(obj1[key], obj2[key]);
81 | }
82 | }
83 |
84 | return mergedObj;
85 | }
86 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userSlugCheck.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import {
3 | User,
4 | UserSlugCheckRequest,
5 | UserSlugCheckResponse,
6 | } from "../models.js";
7 |
8 | export default async (
9 | connection: DataSource,
10 | request: UserSlugCheckRequest
11 | ): Promise => {
12 | let users = await connection
13 | .getRepository(User)
14 | .find({ where: { slug: request.slug } });
15 |
16 | if (!users || users.length == 0) {
17 | return {
18 | taken: false,
19 | };
20 | }
21 |
22 | return {
23 | taken: true,
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/src/service/authentication/user/userUnGhost.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../../util.js";
3 | import * as bcrypt from "bcrypt";
4 | import { nanoid } from "nanoid";
5 | import {
6 | Token,
7 | User,
8 | Password,
9 | Contact,
10 | UserUnGhostRequest,
11 | UserUnGhostResponse,
12 | platformEmail,
13 | } from "../models.js";
14 |
15 | export default async (
16 | connection: DataSource,
17 | request: UserUnGhostRequest
18 | ): Promise => {
19 | let token: Token = await connection
20 | .createQueryBuilder(Token, "token")
21 | .where("token.token = :id", { id: request.token })
22 | .leftJoinAndSelect("token.user", "user")
23 | .getOne();
24 | if (!token) {
25 | throw error("token not found", 400);
26 | }
27 |
28 | let user = new User();
29 | user.id = token.user.id;
30 |
31 | let password = new Password();
32 | let contact = new Contact();
33 | contact.id = nanoid();
34 | contact.platformId = platformEmail.id;
35 | contact.userId = user.id;
36 | contact.url = request.contact.url;
37 |
38 | password.id = await bcrypt.hash(request.password, 10);
39 | password.userId = user.id;
40 |
41 | await connection.transaction(async (tran) => {
42 | await tran.save(password);
43 | await tran.save(contact);
44 | });
45 |
46 | return { user: user };
47 | };
48 |
--------------------------------------------------------------------------------
/src/service/authentication/user/verificationCodeSend.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import {
3 | VerificationCodeSendRequest,
4 | VerificationCodeSendResponse,
5 | User,
6 | Token,
7 | SecretCode,
8 | Secret,
9 | } from "../models.js";
10 | import sendgrid from "@sendgrid/mail";
11 | import { Error, error } from "../../../util.js";
12 | import { nanoid } from "nanoid";
13 | import { ConfigService } from "../../config/index.js";
14 |
15 | // @todo
16 | function getSendgridKey(): string {
17 | return "sg_key";
18 | }
19 |
20 | export default async (
21 | connection: DataSource,
22 | config: ConfigService,
23 | request: VerificationCodeSendRequest
24 | ): Promise => {
25 | let token: Token = await connection
26 | .createQueryBuilder(Token, "token")
27 | .where("token.token = :id", { id: request.token })
28 | .getOne();
29 | if (!token) {
30 | throw error("token not found", 400);
31 | }
32 |
33 | let user: User = await connection
34 | .createQueryBuilder(User, "user")
35 | .where("user.id = :id", { id: token.userId })
36 | .leftJoinAndSelect("user.roles", "role")
37 | .leftJoinAndSelect("user.contacts", "contact")
38 | .getOne();
39 |
40 | let code = new SecretCode();
41 | code.id = nanoid();
42 | code.code = nanoid();
43 | code.userId = user.id;
44 |
45 | let c = await config.secretRead({});
46 | if (c instanceof Error) {
47 | return c;
48 | }
49 | let conf: Secret = c.secret?.data?.AuthenticationService;
50 |
51 | sendgrid.setApiKey(getSendgridKey());
52 | const msg = {
53 | // @todo this only supports a single contract
54 | to: user.contacts[0].url, // Change to your recipient
55 | from: conf?.email?.from, // Change to your verified sender
56 | subject: conf?.email?.register?.subject || "Email confirmation",
57 | text:
58 | conf?.email?.register.text ||
59 | "Szia,\n\nKérünk erősítsd meg az emailedet erre a linkre kattintva:\n\nhttps://kuponjaim.hu/auth/email-verification?code=" +
60 | code.code +
61 | "\n\nA kuponjaim.hu csapata",
62 | };
63 |
64 | await connection.transaction(async (tran) => {
65 | await tran.save(code);
66 | });
67 |
68 | await sendgrid.send(msg);
69 |
70 | return {};
71 | };
72 |
--------------------------------------------------------------------------------
/src/service/authentication/user/verificationCodeVerify.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../../util.js";
3 | import {
4 | Contact,
5 | SecretCode,
6 | User,
7 | VerificationCodeVerifyRequest,
8 | VerificationCodeVerifyResponse,
9 | } from "../models.js";
10 |
11 | export default async (
12 | connection: DataSource,
13 | request: VerificationCodeVerifyRequest
14 | ): Promise => {
15 | let code: SecretCode = await connection
16 | .createQueryBuilder(SecretCode, "code")
17 | .where("code.code = :id", { id: request.code })
18 | .getOne();
19 |
20 | if (!code || !code.id) {
21 | throw error("code not found", 400);
22 | }
23 |
24 | let user: User = await connection
25 | .createQueryBuilder(User, "user")
26 | .where("user.id = :id", { id: code.userId })
27 | .leftJoinAndSelect("user.contacts", "contact")
28 | .getOne();
29 |
30 | if (code.userId != user.id) {
31 | throw error("code is wrong", 400);
32 | }
33 |
34 | await connection.transaction(async (tran) => {
35 | await tran
36 | .createQueryBuilder(Contact, "contact")
37 | .update(Contact)
38 | .where(`contact.url = :url AND contact."platformId" = :platform`, {
39 | url: user.contacts[0].url,
40 | platform: user.contacts[0].platformId,
41 | })
42 | .set({
43 | verified: true,
44 | })
45 | .execute();
46 | });
47 |
48 | return {};
49 | };
50 |
--------------------------------------------------------------------------------
/src/service/authentication/userSave.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "@jest/globals";
2 | import { Injector } from "../../injector.js";
3 | import { nanoid } from "nanoid";
4 | import { AuthenticationService } from "./index.js";
5 |
6 | describe("auth user update", () => {
7 | var auth: AuthenticationService;
8 |
9 | test("setup", async () => {
10 | let namespace = "t_" + nanoid().slice(0, 7);
11 |
12 | let i = new Injector([AuthenticationService]);
13 | auth = await i.getInstance("AuthenticationService", namespace);
14 | });
15 |
16 | test("auth was initiated", async () => {
17 | expect(auth).toBeTruthy();
18 | });
19 |
20 | test("meta tests", async () => {
21 | let rsp = await auth.userRegister({
22 | user: {
23 | contacts: [{ url: "test-3@test.com" }],
24 | },
25 | password: "1011",
26 | });
27 |
28 | await auth.userRegister({
29 | user: {
30 | contacts: [{ url: "test-4@test.com" }],
31 | },
32 | password: "1011",
33 | });
34 |
35 | await auth.userSave({
36 | token: rsp.token.token,
37 | user: {
38 | meta: {
39 | x: "y",
40 | y: true,
41 | obj: {
42 | a: "b",
43 | },
44 | },
45 | },
46 | });
47 |
48 | await auth.userSave({
49 | token: rsp.token.token,
50 | user: {
51 | meta: {
52 | z: 3,
53 | },
54 | },
55 | });
56 | let trsp = await auth.tokenRead({ token: rsp.token.token });
57 | expect(trsp.token.user.meta.x).toBe("y");
58 | expect(trsp.token.user.meta.y).toBe(true);
59 | expect(trsp.token.user.meta.z).toBe(3);
60 |
61 | await auth.userSave({
62 | token: rsp.token.token,
63 | user: {
64 | meta: {
65 | obj: {
66 | b: "c",
67 | },
68 | },
69 | },
70 | });
71 |
72 | trsp = await auth.tokenRead({ token: rsp.token.token });
73 | expect(trsp.token.user.meta.obj.a).toBe("b");
74 | expect(trsp.token.user.meta.obj.b).toBe("c");
75 | });
76 |
77 | test("org update", async () => {
78 | let rsp = await auth.userRegister({
79 | user: {
80 | contacts: [{ url: "test-5@test.com" }],
81 | },
82 | password: "1015",
83 | });
84 |
85 | await auth.userCreateOrganization({
86 | token: rsp.token.token,
87 | organization: {
88 | name: "user test org",
89 | },
90 | });
91 |
92 | await auth.userCreateOrganization({
93 | token: rsp.token.token,
94 | organization: {
95 | name: "user test org",
96 | },
97 | });
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/src/service/config/README.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](../../../docs/README.md)
2 | # Config Service
3 |
4 | This is a config service that supports both loading data from the database or dotenv.
5 |
6 | - [Config Service](#config-service)
7 | - [Concepts](#concepts)
8 | - [Envars](#envars)
9 | - [Conventions](#conventions)
10 | - [Top level values](#top-level-values)
11 | - [Default config/secrets for your services](#default-configsecrets-for-your-services)
12 |
13 | ## Concepts
14 |
15 | The config service deals with two kind of data: `Config` and `Secrets`.
16 |
17 | `Config`s are meant to be readable without authorization. Prime examples are public keys, eg. Facebook app ID, Stripe public key etc.
18 |
19 | `Secrets` are meant to be read by services.
20 |
21 | Secrets are not readable through HTTP, but they can be updated through HTTP with admin rights.
22 |
23 | ## Envars
24 |
25 | All envars starting with `CONFIG_` are loaded into the `data` field of the config.
26 |
27 | All envars starting with `SECRET_` are loaded into the `data` field of the secret.
28 |
29 | ## Conventions
30 |
31 | We advise that you save data into the config service under a key that is unique to your service, the key should most probably be its name.
32 |
33 | See for example how the [`AuthenticationService`](../authentication/README.md) uses the config service:
34 |
35 | ```ts
36 | let srsp = await this.config.secretRead({});
37 | let secret: Secret = srsp.secret.data.AuthenticationService;
38 | ```
39 |
40 | ### Top level values
41 |
42 | There are a few notable top level values which fall outside of the convention mentioned in the previous section:
43 |
44 | Using the notation from the code above:
45 |
46 | ```ts
47 | // tells the system that this is a production instance
48 | // used by eg. the FileService
49 | srsp.secret.data.isProduction
50 | ```
51 |
52 | ### Default config/secrets for your services
53 |
54 | We encourage you to define your own `Secret` type in your service for ease of understanding when someone explores your service, similar to what the [`AuthenticationService`](../authentication/README.md) is doing:
55 |
56 | ```ts
57 | export class Secret {
58 | /** Admin user fullname */
59 | fullName?: string;
60 | /** Admin user email */
61 | adminEmail?: string;
62 | /** Admin user password */
63 | adminPassword?: string;
64 | /** Admin user organization name */
65 | adminOrganization?: string;
66 |
67 | sendgrid?: {
68 | key?: string;
69 | };
70 | email?: {
71 | from?: string;
72 | register?: {
73 | /**
74 | * Subject of
75 | */
76 | subject?: string;
77 | text?: string;
78 | };
79 | };
80 |
81 | // https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow#login
82 | facebookAppID?: string;
83 | facebookAppSecret?: string;
84 | facebookAppRedirectURL?: string;
85 | }
86 |
87 | export const defaultSecret: Secret = {
88 | adminEmail: "example@example.com",
89 | adminPassword: "admin",
90 | adminOrganization: "Admin Org",
91 | fullName: "The Admin",
92 | };
93 | ```
94 |
95 | We advise you to reuse this `Secret` type across your service, ie. use the same structure to save secrets.
96 |
97 | So in practice, you might do something like this:
98 |
99 | ```ts
100 | let srsp = await this.config.secretRead({});
101 | // using your own Secret type to understand data obtained from config service
102 | let secret: Secret = srsp.secret.data.YourServiceName;
103 | ```
--------------------------------------------------------------------------------
/src/service/config/config.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "@jest/globals";
2 | import { Injector } from "../../injector.js";
3 | import { nanoid } from "nanoid";
4 | import { ConfigService } from "./index.js";
5 |
6 | describe("Config tests", () => {
7 | var config: ConfigService;
8 | test("setup", async () => {
9 | let namespace = "t_" + nanoid().slice(0, 7);
10 | let i = new Injector([ConfigService]);
11 | config = await i.getInstance("ConfigService", namespace);
12 | });
13 |
14 | test("config read basics", async () => {
15 | expect(config).toBeTruthy();
16 | let rsp = await config.configRead({});
17 | expect(rsp.config?.data).toBeTruthy();
18 | });
19 |
20 | // test if saved values get deeply merged
21 | test("config value merge test", async () => {
22 | expect(config).toBeTruthy();
23 | let rsp = await config.configSaveS2S({
24 | token: "",
25 | config: {
26 | slogan: "slogan1",
27 | data: {
28 | "test-val1": "test-val1",
29 | },
30 | },
31 | });
32 | expect(rsp.config?.data).toBeTruthy();
33 | expect(rsp.config?.data["test-val1"]).toBe("test-val1");
34 | expect(rsp.config?.slogan).toBe("slogan1");
35 | let rsp2 = await config.configSaveS2S({
36 | token: "",
37 | config: {
38 | domain: "domain1",
39 | data: {
40 | "test-val2": "test-val2",
41 | },
42 | },
43 | });
44 | expect(rsp2.config?.data).toBeTruthy();
45 | expect(rsp2.config?.data["test-val2"]).toBe("test-val2");
46 | expect(rsp2.config?.domain).toBe("domain1");
47 |
48 | let readResp = await config.configRead({});
49 | expect(readResp.config?.data).toBeTruthy();
50 | expect(readResp.config?.data["test-val1"]).toBe("test-val1");
51 | expect(readResp.config?.data["test-val2"]).toBe("test-val2");
52 | expect(readResp.config?.domain).toBe("domain1");
53 | expect(readResp.config?.slogan).toBe("slogan1");
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/src/service/config/configRead.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { Config, ConfigReadResponse } from "./models.js";
3 |
4 | export default async (
5 | connection: DataSource,
6 | request: any
7 | ): Promise => {
8 | let config: Config;
9 | try {
10 | config = await connection.createQueryBuilder(Config, "config").getOne();
11 | } catch (e) {
12 | console.log(e);
13 | return {
14 | config: {
15 | data: {},
16 | },
17 | };
18 | }
19 | if (!config) {
20 | config = {
21 | data: {},
22 | };
23 | }
24 |
25 | // Add env variables to config for local development
26 | // see readme for more info
27 | Object.keys(process.env).forEach(function (key) {
28 | if (!key.startsWith("CONFIG_")) {
29 | return;
30 | }
31 | if (!config?.data) {
32 | config.data = {};
33 | }
34 | let key2 = key
35 | .replace("CONFIG_", "")
36 | .toLowerCase()
37 | // https://stackoverflow.com/questions/6660977/convert-hyphens-to-camel-case-camelcase
38 | .replace(/_([a-z])/g, function (g) {
39 | return g[1].toUpperCase();
40 | });
41 | config.data[key2] = process.env[key];
42 | });
43 |
44 | return {
45 | config: config,
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/src/service/config/configSave.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../util.js";
3 | import { roleAdmin } from "../authentication/models.js";
4 | import { AuthenticationService } from "../authentication/index.js";
5 | import { ConfigSaveRequest, ConfigSaveResponse } from "./models.js";
6 | import configSaveS2S from "./configSaveS2S.js";
7 |
8 | export default async (
9 | connection: DataSource,
10 | depProducer: (serviceName: string) => Promise,
11 | request: ConfigSaveRequest
12 | ): Promise => {
13 | let auth = await depProducer("AuthenticationService");
14 | let token = await auth.tokenRead({
15 | token: request.token,
16 | });
17 | if (token.token.user.roles.find((r) => r.id == roleAdmin.id) == undefined) {
18 | throw error("not authorized", 400);
19 | }
20 | return configSaveS2S(connection, request);
21 | };
22 |
--------------------------------------------------------------------------------
/src/service/config/configSaveS2S.ts:
--------------------------------------------------------------------------------
1 | //import { nanoid } from "nanoid";
2 | import { DataSource } from "typeorm";
3 | import { copy } from "../../util.js";
4 | import { Config, ConfigSaveRequest, ConfigSaveResponse } from "./models.js";
5 |
6 | export default async (
7 | connection: DataSource,
8 | request: ConfigSaveRequest
9 | ): Promise => {
10 | let dbConfig: Config;
11 | try {
12 | dbConfig = await connection.createQueryBuilder(Config, "config").getOne();
13 | } catch (e) {
14 | console.log(e);
15 | return {
16 | config: {},
17 | };
18 | }
19 | if (!dbConfig) {
20 | dbConfig = {
21 | data: {},
22 | };
23 | }
24 |
25 | let config = new Config();
26 | let result = mergeDeep(config, dbConfig, request.config);
27 | copy(result, config);
28 | if (!config.id) {
29 | config.id = "1";
30 | }
31 |
32 | await connection.transaction(async (tran) => {
33 | await tran.save(config);
34 | });
35 |
36 | return {
37 | config: config,
38 | };
39 | };
40 |
41 | // taken from https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge
42 |
43 | /**
44 | * Simple object check.
45 | * @param item
46 | * @returns {boolean}
47 | */
48 | export function isObject(item) {
49 | return item && typeof item === "object" && !Array.isArray(item);
50 | }
51 |
52 | /**
53 | * Deep merge two objects.
54 | * @param target
55 | * @param ...sources
56 | */
57 | export function mergeDeep(target, ...sources) {
58 | if (!sources.length) return target;
59 | const source = sources.shift();
60 |
61 | if (isObject(target) && isObject(source)) {
62 | for (const key in source) {
63 | if (isObject(source[key])) {
64 | if (!target[key]) Object.assign(target, { [key]: {} });
65 | mergeDeep(target[key], source[key]);
66 | } else {
67 | Object.assign(target, { [key]: source[key] });
68 | }
69 | }
70 | }
71 |
72 | return mergeDeep(target, ...sources);
73 | }
74 |
--------------------------------------------------------------------------------
/src/service/config/index.ts:
--------------------------------------------------------------------------------
1 | import { Service, Unexposed } from "../../reflect.js";
2 | import { DataSource } from "typeorm";
3 |
4 | import configRead from "./configRead.js";
5 | import configSave from "./configSave.js";
6 | import configSaveS2S from "./configSaveS2S.js";
7 | import secretSaveS2S from "./secretSaveS2S.js";
8 |
9 | import {
10 | Config,
11 | Secret,
12 | ConfigSaveRequest,
13 | ConfigReadRequest,
14 | SecretReadRequest,
15 | SecretSaveRequest,
16 | } from "./models.js";
17 | import { Servicelike } from "../../util.js";
18 | import { AuthenticationService } from "../authentication/index.js";
19 | import secretRead from "./secretRead.js";
20 | import secretSave from "./secretSave.js";
21 |
22 | @Service()
23 | export class ConfigService implements Servicelike {
24 | meta = {
25 | name: "config",
26 | typeorm: {
27 | entities: [Config, Secret],
28 | },
29 | };
30 |
31 | private connection: DataSource;
32 | /** depProducer pattern is used here to get around circular
33 | * dependencies. The AuthenticationService depends on the
34 | * ConfigService, and the ConfigService depends on the
35 | * AuthenticationService. This is a workaround for that.
36 | */
37 | private depProducer: (serviceName: string) => Promise;
38 |
39 | constructor(
40 | connection: DataSource,
41 | depProducer: (serviceName: string) => Promise
42 | ) {
43 | this.connection = connection;
44 | this.depProducer = depProducer;
45 | }
46 |
47 | configRead(req: ConfigReadRequest) {
48 | return configRead(this.connection, req);
49 | }
50 |
51 | configSave(req: ConfigSaveRequest) {
52 | return configSave(this.connection, this.depProducer, req);
53 | }
54 |
55 | @Unexposed()
56 | configSaveS2S(req: ConfigSaveRequest) {
57 | return configSaveS2S(this.connection, req);
58 | }
59 |
60 | @Unexposed()
61 | secretSaveS2S(req: SecretSaveRequest) {
62 | return secretSaveS2S(this.connection, req);
63 | }
64 |
65 | @Unexposed()
66 | /** Should not be exposed over http */
67 | secretRead(req: SecretReadRequest) {
68 | return secretRead(this.connection, req);
69 | }
70 |
71 | /** Should be exposed over http but authenticated with admin */
72 | secretSave(req: SecretSaveRequest) {
73 | return secretSave(this.connection, this.depProducer, req);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/service/config/models.ts:
--------------------------------------------------------------------------------
1 | // (like default missions, admin accounts etc.) should come from `contets.ts`
2 | import { Entity, PrimaryColumn, Column } from "typeorm";
3 |
4 | export declare type Relation = T;
5 |
6 | /** Public site wide config. */
7 | @Entity()
8 | export class Config {
9 | @PrimaryColumn()
10 | id?: string;
11 |
12 | @Column({ nullable: true })
13 | domain?: string;
14 |
15 | @Column({ nullable: true })
16 | isProduction?: boolean;
17 |
18 | @Column({ nullable: true })
19 | template?: string;
20 |
21 | @Column({ nullable: true })
22 | slogan?: string;
23 |
24 | @Column({ type: "jsonb", nullable: true })
25 | contact?: Relation;
26 |
27 | @Column({ type: "jsonb", nullable: true })
28 | favicon?: Relation;
29 |
30 | @Column({ type: "jsonb", nullable: true })
31 | og?: Relation;
32 |
33 | @Column({ type: "jsonb", nullable: true })
34 | /** Any unstructured data comes here */
35 | data?: { [key: string]: any };
36 | }
37 |
38 | export class FaviconConfig {
39 | url16?: string;
40 | url32?: string;
41 | url48?: string;
42 | }
43 |
44 | export class OgConfig {
45 | title?: string;
46 | description?: string;
47 | siteName?: string;
48 | type?: string;
49 | image?: string;
50 | }
51 |
52 | export class ContactConfig {
53 | /** Used for display on website and for emails sent from site. */
54 | email?: string;
55 | phone?: string;
56 | }
57 |
58 | /**
59 | * Sercret. Can only be saved through HTTP but not read.
60 | * It is only read by other services.
61 | */
62 | @Entity()
63 | export class Secret {
64 | @PrimaryColumn()
65 | id?: string;
66 |
67 | @Column({ type: "jsonb", nullable: true })
68 | /** Any unstructured data comes here */
69 | data?: { [key: string]: any };
70 | }
71 |
72 | export interface ConfigReadRequest {}
73 |
74 | export interface ConfigReadResponse {
75 | config: Config;
76 | }
77 |
78 | export interface ConfigSaveRequest {
79 | token: string;
80 | config: Config;
81 | }
82 |
83 | export interface ConfigSaveResponse {
84 | config: Config;
85 | }
86 |
87 | // No token here as this is not exposed.
88 | export interface SecretReadRequest {}
89 |
90 | export interface SecretReadResponse {
91 | secret: Secret;
92 | }
93 |
94 | export interface SecretSaveRequest {
95 | token: string;
96 | secret: Secret;
97 | }
98 |
99 | export interface SecretSaveResponse {
100 | secret: Secret;
101 | }
102 |
--------------------------------------------------------------------------------
/src/service/config/secretRead.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { Secret, SecretReadResponse } from "./models.js";
3 |
4 | export default async (
5 | connection: DataSource,
6 | request: any
7 | ): Promise => {
8 | let secret: Secret;
9 | try {
10 | secret = await connection.createQueryBuilder(Secret, "secret").getOne();
11 | } catch (e) {
12 | console.log(e);
13 | return {
14 | secret: {
15 | data: {},
16 | },
17 | };
18 | }
19 | if (!secret) {
20 | secret = {
21 | data: {},
22 | };
23 | }
24 |
25 | // Add env variables to secret for local development
26 | // see readme for more info
27 | Object.keys(process.env).forEach(function (key) {
28 | if (!key.startsWith("SECRET_")) {
29 | return;
30 | }
31 | if (!secret?.data) {
32 | secret.data = {};
33 | }
34 | let key2 = key
35 | .replace("SECRET_", "")
36 | .toLowerCase()
37 | // https://stackoverflow.com/questions/6660977/convert-hyphens-to-camel-case-camelcase
38 | .replace(/_([a-z])/g, function (g) {
39 | return g[1].toUpperCase();
40 | });
41 | secret.data[key2] = process.env[key];
42 | });
43 |
44 | return {
45 | secret: secret,
46 | };
47 | };
48 |
--------------------------------------------------------------------------------
/src/service/config/secretSave.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../util.js";
3 | import { roleAdmin } from "../authentication/models.js";
4 | import { AuthenticationService } from "../authentication/index.js";
5 | import { SecretSaveRequest, SecretSaveResponse } from "./models.js";
6 | import secretSaveS2S from "./secretSaveS2S.js";
7 |
8 | export default async (
9 | connection: DataSource,
10 | depProducer: (serviceName: string) => Promise,
11 | request: SecretSaveRequest
12 | ): Promise => {
13 | let auth = await depProducer("AuthenticationService");
14 | let token = await auth.tokenRead({
15 | token: request.token,
16 | });
17 | if (token.token.user.roles.find((r) => r.id == roleAdmin.id) == undefined) {
18 | throw error("not authorized", 400);
19 | }
20 | return secretSaveS2S(connection, request);
21 | };
22 |
--------------------------------------------------------------------------------
/src/service/config/secretSaveS2S.ts:
--------------------------------------------------------------------------------
1 | //import { nanoid } from "nanoid";
2 | import { DataSource } from "typeorm";
3 | import { copy } from "../../util.js";
4 | import { Secret, SecretSaveRequest, SecretSaveResponse } from "./models.js";
5 |
6 | export default async (
7 | connection: DataSource,
8 | request: SecretSaveRequest
9 | ): Promise => {
10 | let dbSecret: Secret;
11 | try {
12 | dbSecret = await connection.createQueryBuilder(Secret, "secret").getOne();
13 | } catch (e) {
14 | console.log(e);
15 | return {
16 | secret: {},
17 | };
18 | }
19 | if (!dbSecret) {
20 | dbSecret = {
21 | data: {},
22 | };
23 | }
24 |
25 | let secret = new Secret();
26 | let result = mergeDeep(secret, dbSecret, request.secret);
27 | copy(result, secret);
28 | if (!secret.id) {
29 | secret.id = "1";
30 | }
31 |
32 | await connection.transaction(async (tran) => {
33 | await tran.save(secret);
34 | });
35 |
36 | return {
37 | secret: secret,
38 | };
39 | };
40 |
41 | // taken from https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge
42 |
43 | /**
44 | * Simple object check.
45 | * @param item
46 | * @returns {boolean}
47 | */
48 | export function isObject(item) {
49 | return item && typeof item === "object" && !Array.isArray(item);
50 | }
51 |
52 |
53 | /**
54 | * Deep merge two objects.
55 | * @param target
56 | * @param ...sources
57 | */
58 | export function mergeDeep(target, ...sources) {
59 | if (!sources.length) return target;
60 | const source = sources.shift();
61 |
62 | if (isObject(target) && isObject(source)) {
63 | for (const key in source) {
64 | if (isObject(source[key])) {
65 | if (!target[key]) Object.assign(target, { [key]: {} });
66 | mergeDeep(target[key], source[key]);
67 | } else {
68 | Object.assign(target, { [key]: source[key] });
69 | }
70 | }
71 | }
72 |
73 | return mergeDeep(target, ...sources);
74 | }
75 |
--------------------------------------------------------------------------------
/src/service/file/README.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](../../../docs/README.md)
2 | # File Service
3 |
4 | - [File Service](#file-service)
5 | - [Concepts](#concepts)
6 | - [File upload](#file-upload)
7 | - [Configuration](#configuration)
8 |
9 | This service is aimed to be a zero config service for file uploads.
10 |
11 | Locally it saves files to your home folder in the `actio-files` folder.
12 |
13 | In production mode currently it supports Google Cloud Storage.
14 | More backends are coming, please open an issue to give ideas to the authors.
15 |
16 | ## Concepts
17 |
18 | As it can be seen my looking at the [models file](./models.ts), a `File` object consists of:
19 |
20 | ```ts
21 | id?: string;
22 | url?: string;
23 | originalName?: string;
24 | size?: number;
25 | ```
26 |
27 | In your services using the `FileService` it is sufficient to save the file URL for simplicity.
28 |
29 | ## File upload
30 |
31 | To upload files, set the `Content-Type` request header to `"multipart/form-data"`.
32 |
33 | The service can accept multiple files.
34 |
35 | ## Configuration
36 |
37 | The service checks the top level `isProduction` config value to decide if to save to the local system or to Google Cloud Storage.
38 |
39 | See [ConfigService](../config/README.md#top-level-values) for more details.
--------------------------------------------------------------------------------
/src/service/file/file.test.ts:
--------------------------------------------------------------------------------
1 | import { test } from "@jest/globals";
2 | import { createApp } from "../../registrator.js";
3 | import { default as request } from "supertest";
4 | import { FileService } from "./index.js";
5 |
6 |
7 | test("file upload", async () => {
8 | let appA = createApp([FileService]);
9 |
10 | const response = await request(appA)
11 | .post("/file/httpFileUpload")
12 | .set("Content-Type", "multipart/form-data")
13 | .attach("file", "/tmp/testfile.png");
14 |
15 | console.log(response.body);
16 | });
17 |
--------------------------------------------------------------------------------
/src/service/file/fileUpload.ts:
--------------------------------------------------------------------------------
1 | // https://cloud.google.com/functions/docs/writing/http#multipart_data_and_file_uploads
2 |
3 | /**
4 | * Parses a 'multipart/form-data' upload request
5 | *
6 | * @param {Object} req Cloud Function request context.
7 | * @param {Object} res Cloud Function response context.
8 | */
9 | import * as path from "path";
10 | import * as os from "os";
11 | import * as fs from "fs";
12 | import { nanoid } from "nanoid";
13 | import * as stream from "stream";
14 | // Imports the Google Cloud client library
15 | import { Storage } from "@google-cloud/storage";
16 | import * as express from "express";
17 |
18 | // Node.js doesn't have a built-in multipart/form-data parsing library.
19 | // Instead, we can use the 'busboy' library from NPM to parse these requests.
20 | import busboy from "busboy";
21 | import { default as env } from "../../env.js";
22 | import { ConfigService } from "../config/index.js";
23 |
24 | // @todo fix this
25 | const actioFolder = "actio-files";
26 |
27 | interface File {
28 | name: string;
29 | path: string;
30 | }
31 | interface FileResponse {
32 | originalName: string;
33 | size: number;
34 | url: string;
35 | }
36 |
37 | export default async (
38 | req: express.Request,
39 | res: express.Response,
40 | config: ConfigService
41 | ) => {
42 | let cfg = await config.configRead({});
43 |
44 | if (req.method !== "POST") {
45 | // Return a "method not allowed" error
46 | return res.status(405).end();
47 | }
48 |
49 | const bb = busboy({ headers: req.headers });
50 | const tmpdir = os.tmpdir();
51 |
52 | // This object will accumulate all the fields, keyed by their name
53 | const fields = {};
54 |
55 | // This object will accumulate all the uploaded files, keyed by their name.
56 | const uploads = {};
57 |
58 | // This code will process each non-file field in the form.
59 | bb.on("field", (fieldname, val) => {
60 | /**
61 | * TODO(developer): Process submitted field values here
62 | */
63 | console.log(`Processed field ${fieldname}: ${val}.`);
64 | fields[fieldname] = val;
65 | });
66 |
67 | const fileWrites = [];
68 |
69 | // This code will process each file uploaded.
70 | bb.on("file", (filename, state, file) => {
71 | // Note: os.tmpdir() points to an in-memory file system on GCF
72 | // Thus, any files in it must fit in the instance's memory.
73 | console.log(`Processed file ${filename}`);
74 | const filepath = path.join(tmpdir, filename);
75 | uploads[filename] = filepath;
76 |
77 | const writeStream = fs.createWriteStream(filepath);
78 | state.pipe(writeStream);
79 |
80 | // File was processed by Busboy; wait for it to be written.
81 | // Note: GCF may not persist saved files across invocations.
82 | // Persistent files must be kept in other locations
83 | // (such as Cloud Storage buckets).
84 | const promise = new Promise((resolve, reject) => {
85 | state.on("end", () => {
86 | writeStream.end();
87 | });
88 | writeStream.on("finish", resolve);
89 | writeStream.on("error", reject);
90 | });
91 | fileWrites.push(promise);
92 | });
93 |
94 | // Triggered once all uploaded files are processed by Busboy.
95 | // We still need to wait for the disk writes (saves) to complete.
96 | bb.on("close", async () => {
97 | console.log("Closing");
98 |
99 | await Promise.all(fileWrites);
100 |
101 | // https://stackoverflow.com/questions/44945376/how-to-upload-an-in-memory-file-data-to-google-cloud-storage-using-nodejs
102 |
103 | var files: File[] = [];
104 | for (const filename in uploads) {
105 | files.push({
106 | name: filename,
107 | path: uploads[filename],
108 | });
109 | }
110 |
111 | var filesRsp: FileResponse[] = [];
112 | await Promise.all(
113 | files.map(async (file) => {
114 | // @todo make this not come from the data
115 | if (cfg.config?.data?.isProduction) {
116 | return uploadToGoogleStorage(filesRsp, cfg.config, file);
117 | } else {
118 | return saveFileToLocation(filesRsp, cfg.config, file);
119 | }
120 | })
121 | );
122 | res
123 | .send(
124 | JSON.stringify({
125 | files: filesRsp,
126 | })
127 | )
128 | .end();
129 | });
130 |
131 | req.pipe(bb);
132 | };
133 |
134 | function saveFileToLocation(
135 | filesRsp: FileResponse[],
136 | cfg: { [key: string]: any },
137 | file: File
138 | ): Promise {
139 | let f = fs.readFileSync(file.path);
140 | fs.writeFile(
141 | path.join(os.homedir(), actioFolder, file.name),
142 | f,
143 | function (err) {
144 | if (err) {
145 | return console.log(err);
146 | }
147 | }
148 | );
149 | // @todo make this async
150 | filesRsp.push({
151 | originalName: file.name,
152 | size: f.length,
153 | url: "http://127.0.0.1:8080/file/httpFileServe/" + file.name,
154 | });
155 | return null;
156 | }
157 |
158 | function uploadToGoogleStorage(
159 | filesRsp: FileResponse[],
160 | cfg: { [key: string]: any },
161 | file: File
162 | ): Promise {
163 | const id = nanoid();
164 |
165 | // Creates a client
166 | const cloudStorage = new Storage();
167 | let dataStream = new stream.PassThrough();
168 |
169 | let fileName = id + "." + file.name.split(".")[1];
170 | let gcFile = cloudStorage
171 | .bucket(cfg.bucketName)
172 | // get extension from file
173 | .file(fileName);
174 |
175 | // file was sent under key "file"
176 | // this endpoint currently only support saving a single file
177 | let f = fs.readFileSync(file.path);
178 | // baseurl example https://storage.googleapis.com/storage/v1
179 | let baseUrl = cloudStorage.baseUrl;
180 | if (env.isProd) {
181 | // need to truncate this as baseurl != image link base url for some reason
182 | baseUrl = baseUrl.replace("/storage/v1", "");
183 | }
184 | filesRsp.push({
185 | originalName: file.name,
186 | size: f.length,
187 | url: baseUrl + "/" + cfg.bucketName + "/" + fileName,
188 | });
189 | dataStream.push(f);
190 | dataStream.push(null);
191 |
192 | return new Promise((resolve, reject) => {
193 | dataStream
194 | .pipe(
195 | gcFile.createWriteStream({
196 | resumable: false,
197 | validation: false,
198 | public: true,
199 | metadata: { "Cache-Control": "public, max-age=31536000" },
200 | })
201 | )
202 | .on("error", (error: Error) => {
203 | reject(error);
204 | })
205 | .on("finish", () => {
206 | //for (const file in uploads) {
207 | // fs.unlinkSync(uploads[file]);
208 | //}
209 | resolve();
210 | });
211 | });
212 | }
213 |
--------------------------------------------------------------------------------
/src/service/file/httpFileServe.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Parses a 'multipart/form-data' upload request
3 | *
4 | * @param {Object} req Cloud Function request context.
5 | * @param {Object} res Cloud Function response context.
6 | */
7 | import * as express from "express";
8 | import * as path from "path";
9 | import * as os from "os";
10 |
11 | import { ConfigService } from "../config/index.js";
12 |
13 | const actioFolder = "actio-files";
14 |
15 | export default async (
16 | req: express.Request,
17 | res: express.Response,
18 | config: ConfigService
19 | ) => {
20 | // example path "http://127.0.0.1:8080/file/httpFileServe/img-id-54321.jpeg"
21 | let parts = req.originalUrl.split("/");
22 |
23 | if (parts.length < 4) {
24 | throw "file path malformed";
25 | }
26 |
27 | console.log("serving file", path.basename(parts[3]));
28 | res;
29 | res
30 | .contentType(path.basename(parts[3]))
31 | // @todo get this from config
32 | .sendFile(path.join(os.homedir(), actioFolder, parts[3]));
33 | };
34 |
--------------------------------------------------------------------------------
/src/service/file/index.ts:
--------------------------------------------------------------------------------
1 | //import teardown from "./teardown";
2 | import fileUpload from "./fileUpload.js";
3 | import fileServe from "./httpFileServe.js";
4 |
5 | import { File } from "./models.js";
6 | import { Service, Raw } from "../../reflect.js";
7 | import { Servicelike } from "../../util.js";
8 | import { ConfigService } from "../config/index.js";
9 | import * as fs from "fs";
10 | import * as express from "express";
11 | import * as path from "path";
12 | import * as os from "os";
13 |
14 | const actioFolder = "actio-files";
15 |
16 | @Service()
17 | export class FileService implements Servicelike {
18 | private config: ConfigService;
19 |
20 | constructor(config: ConfigService) {
21 | this.config = config;
22 | }
23 |
24 | meta = {
25 | name: "file",
26 | typeorm: {
27 | entities: [File],
28 | },
29 | };
30 |
31 | @Raw()
32 | httpFileUpload(req: express.Request, rsp: express.Response) {
33 | fileUpload(req, rsp, this.config);
34 | }
35 |
36 | /**
37 | * Serve files in a local environments.
38 | * In production environments files will be served by Google Cloud
39 | * Storage or Amazon S3 etc (so this endpoint is only used durin
40 | * local development).
41 | *
42 | * In local environments the file upload returns an url like
43 | * "http:127.0.0.1:8080/file/httpFileServe/img-id-54321.jpeg"
44 | *
45 | * In production environments file upload returns an url like
46 | * "https://https://storage.googleapis.com/example-bucket/img-id-54321.jpg.jpeg"
47 | */
48 | @Raw()
49 | httpFileServe(req: express.Request, rsp: express.Response) {
50 | fileServe(req, rsp, this.config);
51 | }
52 |
53 | _onInit(): Promise {
54 | // for local development only
55 | fs.promises.mkdir(path.join(os.homedir(), actioFolder), {
56 | recursive: true,
57 | });
58 | return null;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/service/file/models.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, PrimaryColumn } from "typeorm";
2 | import { nanoid } from "nanoid";
3 |
4 | export function copy(from, to) {
5 | if (!from || !to) {
6 | return;
7 | }
8 | for (const [key, value] of Object.entries(from)) {
9 | to[key] = value;
10 | }
11 | }
12 | @Entity()
13 | export class File {
14 | constructor(json?: any) {
15 | copy(json, this);
16 | if (!this.id) {
17 | this.id = nanoid();
18 | }
19 | }
20 | @PrimaryColumn()
21 | id?: string;
22 |
23 | @Column()
24 | url?: string;
25 |
26 | @Column()
27 | originalName?: string;
28 |
29 | @Column()
30 | size?: number;
31 |
32 | //@CreateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" })
33 | //createdAt?: string;
34 | //
35 | //@UpdateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" })
36 | //updatedAt?: string;
37 | }
38 |
39 | export interface FileUploadResponse {
40 | files: File[];
41 | }
42 |
--------------------------------------------------------------------------------
/src/service/file/testfile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crufters/actio/a76073680f1682a2e45315fe2ad748d58ca72baf/src/service/file/testfile.png
--------------------------------------------------------------------------------
/src/service/keyvalue/README.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](../../../docs/README.md)
2 |
3 | # KeyValue service
4 |
5 | - [KeyValue service](#keyvalue-service)
6 | - [Use case](#use-case)
7 | - [Fields](#fields)
8 | - [key](#key)
9 | - [namespace](#namespace)
10 | - [ownedByUser](#ownedbyuser)
11 | - [public](#public)
12 | - [publicWrite](#publicwrite)
13 |
14 | Sometimes it is useful for frontend engineers to be able to save unstructured data into the database without bothering backend engineers to add yet another tables/fields/endpoints.
15 |
16 | The `KeyValue` service serves this need.
17 |
18 | Unfortunately it is hard to write a generic service for this purpose without knowing the authorization rules of the specific application, but the `KeyValue` service sidesteps this issue by assigning ownership of a `Key` to the `User` or `Department` that first created it.
19 |
20 | ## Use case
21 |
22 | For an imperfect* example consider the following:
23 |
24 | You are a Reddit developer and want to save unstructured theming options for subreddits. Now assuming the moderators all belong to the same `Department`, all you have to do is to set the value like this:
25 |
26 | ```ts
27 | await keyValueService.set({
28 | token: myToken,
29 | value: {
30 | key: "$subreddit-id",
31 | namespace: "themingOptions",
32 | // key will be owned by the department and not the
33 | // user that saves this key
34 | ownedByUser: false,
35 | // public means the options will be publicly readable
36 | // ie. without a token
37 | public: true,
38 | // department of the subreddit
39 | departmentId: "$subbredit-department-id"
40 | value: { 'background-color': '#ff0000' },
41 | },
42 | });
43 | ```
44 |
45 | After saving the above values will only be settable by the subreddit moderators.
46 |
47 | An important limitation here to understand is that the key must be "acquired" ideally before the subreddit ID is published, otherwise some other department might "take it".
48 |
49 | * imperfect because at the moment Actio doesn't work well with when a user belongs to a huge number of subreddits, but this is easy to fix and will be fixed later.
50 |
51 | ## Fields
52 |
53 | Below are the fields for the `Value` type that this service accepts (see [`models.ts`](./models.ts)).
54 |
55 | ### key
56 |
57 | The key : ). ([Everyone knows what a horse is](https://en.wikipedia.org/wiki/Nowe_Ateny#:~:text=Nowe%20Ateny%20is%20the%20source,a%20stinking%20kind%20of%20animal.)).
58 |
59 | This is most likely an object id that exists somewhere else in your application. A subreddit ID, a webshop ID, a user ID etc.
60 |
61 | ### namespace
62 |
63 | The namespace simply exists so you can save multiple things for a key. For a user ID key you could save namespaces like "preferences", "settings", "history" etc.
64 |
65 | ### ownedByUser
66 |
67 | If true, it is the user that owns the key and not their department.
68 |
69 | ### public
70 |
71 | If true then the key will be publicly (ie. without a token) readable.
72 |
73 | ### publicWrite
74 |
75 | If true, the key will have no concept of ownership, anyone can write or read it.
--------------------------------------------------------------------------------
/src/service/keyvalue/get.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { Value, GetRequest, GetResponse } from "./models.js";
3 | import { AuthenticationService } from "../authentication/index.js";
4 | import { Role } from "../authentication/models.js";
5 |
6 | export default async function (
7 | connection: DataSource,
8 | auth: AuthenticationService,
9 | req: GetRequest
10 | ): Promise {
11 | let v = await connection
12 | .createQueryBuilder(Value, "value")
13 | .where("value.key = :key", { key: req.key })
14 | .andWhere("value.namespace = :namespace", { namespace: req.namespace })
15 | .getOne();
16 |
17 | if (!v) {
18 | return {
19 | value: null,
20 | };
21 | }
22 |
23 | if (!v.public) {
24 | if (!req.token) {
25 | throw new Error("no permission");
26 | }
27 | let tokenRsp = await auth.tokenRead({
28 | token: req.token,
29 | });
30 | if (v.ownedByUser && v.userId != tokenRsp.token.user.id) {
31 | throw new Error("no permission");
32 | }
33 | if (!v.ownedByUser) {
34 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles);
35 | if (!departmentIds.includes(v.departmentId)) {
36 | throw new Error("no permission");
37 | }
38 | }
39 | }
40 |
41 | return {
42 | value: v,
43 | };
44 | }
45 |
46 | function getDepartmentIds(roles: Role[]): string[] {
47 | return roles
48 | .filter((r) => r.key.includes("department:"))
49 | .map((r) => r.key.split(":")[1]);
50 | }
51 |
--------------------------------------------------------------------------------
/src/service/keyvalue/index.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import * as m from "./models.js";
3 | import { Servicelike } from "../../util.js";
4 | import { Service } from "../../reflect.js";
5 | import { AuthenticationService } from "../authentication/index.js";
6 | import get from "./get.js";
7 | import set from "./set.js";
8 | import list from "./list.js";
9 |
10 | @Service()
11 | export class KeyValueService implements Servicelike {
12 | meta = {
13 | name: "key-value",
14 | typeorm: {
15 | entities: [m.Value],
16 | },
17 | };
18 |
19 | private connection: DataSource;
20 | private auth: AuthenticationService;
21 |
22 | constructor(connection: DataSource, auth: AuthenticationService) {
23 | this.connection = connection;
24 | this.auth = auth;
25 | }
26 |
27 | set(req: m.SetRequest) {
28 | return set(this.connection, this.auth, req);
29 | }
30 |
31 | get(req: m.GetRequest) {
32 | return get(this.connection, this.auth, req);
33 | }
34 |
35 | list(req: m.ListRequest) {
36 | return list(this.connection, this.auth, req);
37 | }
38 |
39 | async _onInit(): Promise {
40 | return null;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/service/keyvalue/kv.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "@jest/globals";
2 | import { Injector } from "../../injector.js";
3 | import { nanoid } from "nanoid";
4 | import { KeyValueService } from "./index.js";
5 | import { AuthenticationService } from "../authentication/index.js";
6 | import { Value } from "./models.js";
7 |
8 | describe("keyvalue", () => {
9 | var serv: KeyValueService;
10 | var auth: AuthenticationService;
11 |
12 | // users belonging to two different
13 | // departments
14 | var tok1;
15 | var tok2;
16 | test("setup", async () => {
17 | let namespace = "t_" + nanoid().slice(0, 7);
18 | let i = new Injector([KeyValueService]);
19 | serv = await i.getInstance("KeyValueService", namespace);
20 | auth = await i.getInstance("AuthenticationService", namespace);
21 | tok1 = (await auth.tokenAdminGet({})).token.token;
22 | let rsp = await auth.userRegister({
23 | password: "123456",
24 | user: {
25 | contacts: [
26 | {
27 | url: "something@something.com",
28 | },
29 | ],
30 | },
31 | });
32 | tok2 = rsp.token.token;
33 | await auth.userCreateOrganization({
34 | token: tok2,
35 | organization: {
36 | name: "some org",
37 | },
38 | });
39 | });
40 |
41 | test("public value readable without token", async () => {
42 | await serv.set({
43 | token: tok1,
44 | value: {
45 | key: "key1",
46 | namespace: "test",
47 | ownedByUser: true,
48 | public: true,
49 | value: { hi: "there" },
50 | },
51 | });
52 |
53 | let readRsp = await serv.get({
54 | key: "key1",
55 | namespace: "test",
56 | });
57 | expect(readRsp.value.value).toEqual({ hi: "there" });
58 | expect(readRsp.value.public).toBeTruthy();
59 | expect(readRsp.value.ownedByUser).toBeTruthy();
60 | expect(readRsp.value.userId).toBeTruthy();
61 | expect(readRsp.value.key).toEqual("key1");
62 | expect(readRsp.value.namespace).toEqual("test");
63 | });
64 |
65 | test("public value not writable by non owner", async () => {
66 | let val: Value = {
67 | key: "key2",
68 | namespace: "test",
69 | ownedByUser: true,
70 | public: true,
71 | value: { hi: "there" },
72 | };
73 | await serv.set({
74 | token: tok1,
75 | value: val,
76 | });
77 |
78 | // update by non owner should fail
79 | let errored = false;
80 | try {
81 | await serv.set({
82 | token: tok2,
83 | value: val,
84 | });
85 | } catch (e) {
86 | errored = true;
87 | }
88 | expect(errored).toBeTruthy();
89 |
90 | // update by owner
91 | val.value = { hi2: "there2" };
92 | await serv.set({
93 | token: tok1,
94 | value: val,
95 | });
96 | let readRsp = await serv.get({
97 | key: "key2",
98 | namespace: "test",
99 | });
100 | expect(readRsp.value.value).toEqual({ hi: "there", hi2: "there2" });
101 | });
102 |
103 | test("deep merge", async () => {
104 | let val: Value = {
105 | key: "key3",
106 | namespace: "test",
107 | ownedByUser: true,
108 | public: true,
109 | value: {
110 | level1: {
111 | level2: {
112 | a: 1,
113 | },
114 | },
115 | },
116 | };
117 | await serv.set({
118 | token: tok1,
119 | value: val,
120 | });
121 |
122 | let val1: Value = {
123 | key: "key3",
124 | namespace: "test",
125 | ownedByUser: true,
126 | public: true,
127 | value: {
128 | level1: {
129 | level2: {
130 | b: 2,
131 | },
132 | },
133 | },
134 | };
135 | await serv.set({
136 | token: tok1,
137 | value: val1,
138 | });
139 |
140 | let readRsp = await serv.get({
141 | token: tok1,
142 | key: "key3",
143 | namespace: "test",
144 | });
145 | expect(readRsp.value.value).toEqual({
146 | level1: {
147 | level2: {
148 | a: 1,
149 | b: 2,
150 | },
151 | },
152 | });
153 |
154 | let val3: Value = {
155 | key: "key3",
156 | namespace: "test",
157 | ownedByUser: true,
158 | public: true,
159 | value: {
160 | level1: {
161 | level2: {
162 | a: 3,
163 | },
164 | },
165 | },
166 | };
167 | await serv.set({
168 | token: tok1,
169 | value: val3,
170 | });
171 |
172 | readRsp = await serv.get({
173 | token: tok1,
174 | key: "key3",
175 | namespace: "test",
176 | });
177 | expect(readRsp.value.value).toEqual({
178 | level1: {
179 | level2: {
180 | a: 3,
181 | b: 2,
182 | },
183 | },
184 | });
185 | });
186 |
187 | test("list public values", async () => {
188 | await serv.set({
189 | token: tok1,
190 | value: {
191 | key: "publicKey1",
192 | namespace: "test2",
193 | public: true,
194 | value: { data: "value1" },
195 | },
196 | });
197 | await serv.set({
198 | token: tok1,
199 | value: {
200 | key: "publicKey2",
201 | namespace: "test2",
202 | public: true,
203 | value: { data: "value2" },
204 | },
205 | });
206 |
207 | let listRsp = await serv.list({ namespace: "test2" });
208 | expect(listRsp.values).toHaveLength(2);
209 | expect(listRsp.values.some((v) => v.key === "publicKey1")).toBeTruthy();
210 | expect(listRsp.values.some((v) => v.key === "publicKey2")).toBeTruthy();
211 | });
212 |
213 | test("list private values with token", async () => {
214 | await serv.set({
215 | token: tok1,
216 | value: {
217 | key: "privateKey1",
218 | namespace: "test3",
219 | public: false,
220 | value: { data: "value1" },
221 | },
222 | });
223 |
224 | let listRsp = await serv.list({ namespace: "test3", token: tok1 });
225 | expect(listRsp.values.some((v) => v.key === "privateKey1")).toBeTruthy();
226 | });
227 |
228 | test("unauthorized access to private values", async () => {
229 | let listRspNoToken = await serv.list({ namespace: "test3" });
230 |
231 | expect(listRspNoToken.values.some((v) => v.public === false)).toBeFalsy();
232 | });
233 | });
234 |
--------------------------------------------------------------------------------
/src/service/keyvalue/list.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { Value, ListRequest, ListResponse } from "./models.js";
3 | import { AuthenticationService } from "../authentication/index.js";
4 | import { Role, roleAdmin } from "../authentication/models.js";
5 |
6 | export default async function list(
7 | connection: DataSource,
8 | auth: AuthenticationService,
9 | req: ListRequest
10 | ): Promise {
11 | let values = await connection
12 | .createQueryBuilder(Value, "value")
13 | .where("value.namespace = :namespace", { namespace: req.namespace })
14 | .getMany();
15 |
16 | if (!values.length) {
17 | return {
18 | values: [],
19 | };
20 | }
21 |
22 | let filteredValues = [];
23 |
24 | for (let v of values) {
25 | if (v.public) {
26 | filteredValues.push(v);
27 | } else {
28 | if (!req.token) {
29 | continue;
30 | }
31 | let tokenRsp = await auth.tokenRead({ token: req.token });
32 | let isAdmin = false;
33 | if (tokenRsp.token.user.roles?.find((r) => r.id == roleAdmin.id)) {
34 | isAdmin = true;
35 | }
36 | if (!isAdmin && v.ownedByUser && v.userId !== tokenRsp.token.user.id) {
37 | continue;
38 | }
39 | if (!v.ownedByUser) {
40 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles);
41 | if (!departmentIds.includes(v.departmentId)) {
42 | continue;
43 | }
44 | }
45 | filteredValues.push(v);
46 | }
47 | }
48 |
49 | return {
50 | values: filteredValues,
51 | };
52 | }
53 |
54 | function getDepartmentIds(roles: Role[]): string[] {
55 | return roles
56 | .filter((r) => r.key.includes("department:"))
57 | .map((r) => r.key.split(":")[1]);
58 | }
59 |
--------------------------------------------------------------------------------
/src/service/keyvalue/models.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | Column,
4 | PrimaryColumn,
5 | CreateDateColumn,
6 | UpdateDateColumn,
7 | } from "typeorm";
8 | import { copy } from "../file/models.js";
9 | import { nanoid } from "nanoid";
10 |
11 | @Entity()
12 | export class Value {
13 | constructor(json?: any) {
14 | if (!json) return;
15 | copy(json, this);
16 | if (!this.id) {
17 | this.id = nanoid();
18 | }
19 | }
20 |
21 | @PrimaryColumn()
22 | id?: string;
23 |
24 | @Column({
25 | type: "jsonb",
26 | array: false,
27 | nullable: true,
28 | })
29 | value?: { [key: string]: any };
30 |
31 | /**
32 | * eg. "home", "apps", "notification"
33 | */
34 | @Column({ nullable: true })
35 | namespace?: string;
36 |
37 | @Column({ nullable: true })
38 | key?: string;
39 |
40 | /**
41 | * Should this be readable by users who are not part
42 | * of the department?
43 | */
44 | @Column({ nullable: true })
45 | public?: boolean;
46 |
47 | /**
48 | * Should this be writable by users who are not part
49 | * of the department?
50 | */
51 | @Column({ nullable: true })
52 | publicWrite?: boolean;
53 |
54 | /**
55 | * Should this be writable only by the user who created it
56 | * or by anyone in the department?
57 | */
58 | @Column({ nullable: true })
59 | ownedByUser?: boolean;
60 |
61 | @Column({ nullable: true })
62 | userId?: string;
63 |
64 | @Column({ nullable: true })
65 | departmentId?: string;
66 |
67 | @CreateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" })
68 | createdAt?: string;
69 |
70 | @UpdateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" })
71 | updatedAt?: string;
72 | }
73 |
74 | export class GetRequest {
75 | token?: string;
76 | namespace?: string;
77 | key?: string;
78 | }
79 |
80 | export class GetResponse {
81 | value?: Value;
82 | }
83 |
84 | export class SetRequest {
85 | token?: string;
86 | value?: Value;
87 | }
88 |
89 | export class SetResponse {
90 | value?: Value;
91 | }
92 |
93 | export class ListRequest {
94 | token?: string;
95 | namespace?: string;
96 | }
97 |
98 | export class ListResponse {
99 | values: Value[];
100 | }
--------------------------------------------------------------------------------
/src/service/keyvalue/set.ts:
--------------------------------------------------------------------------------
1 | import { AuthenticationService } from "../authentication/index.js";
2 | import { Role } from "../authentication/models.js";
3 | import { DataSource } from "typeorm";
4 | import { Value, SetRequest, SetResponse } from "./models.js";
5 |
6 | export default async (
7 | connection: DataSource,
8 | auth: AuthenticationService,
9 | req: SetRequest
10 | ): Promise => {
11 | let value: Value = new Value(req.value);
12 |
13 | let existingValue = await connection
14 | .createQueryBuilder(Value, "value")
15 | .where("value.key = :key", { key: value.key })
16 | .andWhere("value.namespace = :namespace", { namespace: value.namespace })
17 | .getOne();
18 |
19 | if (!existingValue) {
20 | await saveNewValue(auth, connection, req, value);
21 | return {
22 | value: value,
23 | };
24 | }
25 |
26 | // can't change publicity to hijack a public value
27 | value.publicWrite = existingValue.publicWrite;
28 | value.id = existingValue.id;
29 |
30 | // authorization
31 | if (!existingValue.publicWrite) {
32 | let tokenRsp = await auth.tokenRead({
33 | token: req.token,
34 | });
35 |
36 | if (
37 | existingValue.ownedByUser &&
38 | existingValue.userId != tokenRsp.token.user.id
39 | ) {
40 | throw new Error("no permission");
41 | }
42 | if (!existingValue.ownedByUser) {
43 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles);
44 | if (!departmentIds.includes(existingValue.departmentId)) {
45 | throw new Error("no permission");
46 | }
47 | }
48 | }
49 |
50 | value.value = mergeDeep(existingValue.value, value.value);
51 |
52 | await connection.transaction(async (tran) => {
53 | await tran.save(value);
54 | });
55 |
56 | return {
57 | value: value,
58 | };
59 | };
60 |
61 | async function saveNewValue(
62 | auth: AuthenticationService,
63 | connection: DataSource,
64 | req: SetRequest,
65 | value: Value
66 | ) {
67 | // for non public write values we need to set up ownership
68 | if (!value.publicWrite) {
69 | let tokenRsp = await auth.tokenRead({
70 | token: req.token,
71 | });
72 | value.userId = tokenRsp.token.user.id;
73 |
74 | // if not owned by the user, we need to check the departments
75 | if (!value.ownedByUser) {
76 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles);
77 | if (value.departmentId && !departmentIds.includes(value.departmentId)) {
78 | throw new Error("no permission");
79 | }
80 | if (!value.departmentId && departmentIds.length != 1) {
81 | throw new Error("cannot decide department");
82 | }
83 | if (!value.departmentId) {
84 | value.departmentId = departmentIds[0];
85 | }
86 | }
87 | }
88 |
89 | return connection.transaction(async (tran) => {
90 | await tran.save(value);
91 | });
92 | }
93 |
94 | function getDepartmentIds(roles: Role[]): string[] {
95 | return roles
96 | .filter((r) => r.key.includes("department:"))
97 | .map((r) => r.key.split(":")[1]);
98 | }
99 |
100 | export function isObject(item) {
101 | return item && typeof item === "object" && !Array.isArray(item);
102 | }
103 |
104 | export function mergeDeep(target, ...sources) {
105 | if (!sources.length) return target;
106 | const source = sources.shift();
107 |
108 | if (isObject(target) && isObject(source)) {
109 | for (const key in source) {
110 | if (isObject(source[key])) {
111 | if (!target[key]) Object.assign(target, { [key]: {} });
112 | mergeDeep(target[key], source[key]);
113 | } else {
114 | Object.assign(target, { [key]: source[key] });
115 | }
116 | }
117 | }
118 |
119 | return mergeDeep(target, ...sources);
120 | }
121 |
--------------------------------------------------------------------------------
/src/service/payment/README.md:
--------------------------------------------------------------------------------
1 | # Payment
2 |
3 | This service is aims to abstract away payment providers.
4 | Currently only supports Stripe, and only supports payments where every time you have to pass in your card details.
5 |
6 | The token is created on the frontend and can be only used once.
7 |
8 | ## Config
9 |
10 | ```js
11 | config.data?.PaymentService.stripe_public_key;
12 | ```
13 |
14 | or from envars
15 |
16 | ```sh
17 | PAYMENT_STRIPE_PUBLIC_KEY
18 | ```
19 |
20 | ## Secrets
21 |
22 | Secrets are either read from the config service:
23 |
24 | ```js
25 | secret.data?.PaymentService.stripe_api_key;
26 | ```
27 |
28 | or from envars
29 |
30 | ```sh
31 | SECRET_PAYMENT_STRIPE_SECRET_KEY
32 | ```
33 |
--------------------------------------------------------------------------------
/src/service/payment/balance.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { BalanceRequest, BalanceResponse } from "./models.js";
3 |
4 | import { AuthenticationService } from "../authentication/index.js";
5 |
6 | export default async (
7 | connection: DataSource,
8 | auth: AuthenticationService,
9 | req: BalanceRequest
10 | ): Promise => {
11 | let token = await auth.tokenRead({
12 | token: req.token,
13 | });
14 |
15 | let result = await connection.query(
16 | `select sum(credit) - sum(debit) as balance from
17 | transaction_entry te
18 | inner join account a on a.id = te."accountId"
19 | where a."userId" = '${token.token.userId}'
20 | and a.type = 'internal'`
21 | );
22 |
23 | return {
24 | balance: parseFloat(result[0].balance) || 0,
25 | };
26 | };
27 |
--------------------------------------------------------------------------------
/src/service/payment/charges.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { ChargesRequest, ChargesResponse, Charge } from "./models.js";
3 |
4 | import { AuthenticationService } from "../authentication/index.js";
5 |
6 | export default async (
7 | connection: DataSource,
8 | auth: AuthenticationService,
9 | req: ChargesRequest
10 | ): Promise => {
11 | let token = await auth.tokenRead({
12 | token: req.token,
13 | });
14 |
15 | let charges = await connection
16 | .createQueryBuilder(Charge, "payment")
17 | .where(`payment."userId" = :userId`, { userId: token.token.userId })
18 | .getMany();
19 |
20 | return {
21 | charges: charges,
22 | };
23 | };
24 |
--------------------------------------------------------------------------------
/src/service/payment/index.ts:
--------------------------------------------------------------------------------
1 | import { Service } from "../../reflect.js";
2 | import { Servicelike } from "../../util.js";
3 | import { DataSource } from "typeorm";
4 |
5 | import {
6 | Config,
7 | Secret,
8 | TopupRequest,
9 | PublicKeyRequest,
10 | BalanceRequest,
11 | ChargesRequest,
12 | Charge,
13 | Gateway,
14 | gateways,
15 | Account,
16 | accounts,
17 | Transaction,
18 | TransactionEntry,
19 | PayWithBalanceRequest,
20 | SystemBalanceRequest,
21 | } from "./models.js";
22 | import { ConfigService } from "../config/index.js";
23 | import topup from "./topup.js";
24 | import balance from "./balance.js";
25 | import payments from "./charges.js";
26 | import payWithBalance from "./payWithBalance.js";
27 | import systemBalance from "./systemBalance.js";
28 | import { AuthenticationService } from "../authentication/index.js";
29 |
30 | @Service()
31 | export class PaymentService implements Servicelike {
32 | meta = {
33 | name: "payment",
34 | typeorm: {
35 | entities: [Charge, Gateway, Account, Transaction, TransactionEntry],
36 | },
37 | };
38 |
39 | private connection: DataSource;
40 | private config: ConfigService;
41 | private auth: AuthenticationService;
42 | private stripeApiKey: string;
43 | private stripePublicKey: string;
44 |
45 | // if set to true, stripe calls will be skipped
46 | // useful for testing
47 | public skipStripe = false;
48 |
49 | constructor(
50 | connection: DataSource,
51 | config: ConfigService,
52 | auth: AuthenticationService
53 | ) {
54 | this.connection = connection;
55 | this.config = config;
56 | this.auth = auth;
57 | }
58 |
59 | async _onInit(): Promise {
60 | await Promise.all(
61 | gateways.map(async (p) => {
62 | return await this.connection.getRepository(Gateway).save(p);
63 | })
64 | );
65 | await Promise.all(
66 | accounts.map(async (p) => {
67 | return await this.connection.getRepository(Account).save(p);
68 | })
69 | );
70 |
71 | let cf = await this.config.configRead({});
72 | let config: Config = cf.config.data?.PaymentService;
73 | this.stripePublicKey = config?.stripe_public_key;
74 | let secr = await this.config.secretRead({});
75 | let secret: Secret = secr.secret.data?.PaymentService;
76 | this.stripeApiKey = secret?.stripe_api_key;
77 | if (!this.stripeApiKey) {
78 | this.stripeApiKey = process.env.PAYMENT_STRIPE_SECRET_KEY;
79 | }
80 | if (!this.stripePublicKey) {
81 | this.stripePublicKey = process.env.PAYMENT_STRIPE_PUBLIC_KEY;
82 | }
83 | }
84 |
85 | /** Charge a credit or debit card to pop up the user's balance */
86 | topup(req: TopupRequest) {
87 | return topup(
88 | this.connection,
89 | this.auth,
90 | req,
91 | this.stripeApiKey,
92 | this.skipStripe
93 | );
94 | }
95 |
96 | /** Payment provider public keys to be used on the frontend */
97 | publicKey(req: PublicKeyRequest) {
98 | return {
99 | publicKey: this.stripePublicKey,
100 | };
101 | }
102 |
103 | /** Returns the caller user's balance */
104 | balance(req: BalanceRequest) {
105 | return balance(this.connection, this.auth, req);
106 | }
107 |
108 | /** Returns charges that happened to the caller user's card */
109 | charges(req: ChargesRequest) {
110 | return payments(this.connection, this.auth, req);
111 | }
112 |
113 | payWithBalance(req: PayWithBalanceRequest) {
114 | return payWithBalance(this.connection, this.auth, req);
115 | }
116 |
117 | systemBalance(req: SystemBalanceRequest) {
118 | return systemBalance(this.connection, this.auth, req);
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/service/payment/payWithBalance.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../util.js";
3 | import {
4 | PayWithBalanceRequest,
5 | PayWithBalanceResponse,
6 | Account,
7 | AccountType,
8 | Transaction,
9 | TransactionEntry,
10 | accountCompanyStripe,
11 | } from "./models.js";
12 | import { nanoid } from "nanoid";
13 | import { AuthenticationService } from "../authentication/index.js";
14 |
15 | export default async (
16 | connection: DataSource,
17 | auth: AuthenticationService,
18 | req: PayWithBalanceRequest
19 | ): Promise => {
20 | let token = await auth.tokenRead({
21 | token: req.token,
22 | });
23 |
24 | if (!req.amount) {
25 | throw error("amount missing", 400);
26 | }
27 | if (!req.token) {
28 | throw error("token missing", 400);
29 | }
30 | if (!req.currency) {
31 | throw error("currency missing", 400);
32 | }
33 |
34 | let internalUserAccount = await connection
35 | .createQueryBuilder(Account, "account")
36 | .where(`account."userId" = :userId`, { userId: token.token.userId })
37 | .andWhere(`account.type = :type`, { type: AccountType.Internal })
38 | .getOne();
39 |
40 | if (!internalUserAccount) {
41 | throw error("internal user account not found", 500);
42 | }
43 |
44 | let transaction = new Transaction();
45 | transaction.id = nanoid();
46 | transaction.itemId = req.itemId;
47 |
48 | let debitUserEntry = new TransactionEntry();
49 | debitUserEntry.id = nanoid();
50 | debitUserEntry.accountId = internalUserAccount.id;
51 | debitUserEntry.debit = req.amount;
52 | debitUserEntry.credit = 0;
53 |
54 | let creditCompanyEntry = new TransactionEntry();
55 | creditCompanyEntry.id = nanoid();
56 | creditCompanyEntry.accountId = accountCompanyStripe.id;
57 | creditCompanyEntry.debit = 0;
58 | creditCompanyEntry.credit = req.amount;
59 |
60 | await connection.transaction(async (tran) => {
61 | await tran.save(transaction);
62 | await tran.save(debitUserEntry);
63 | await tran.save(creditCompanyEntry);
64 | });
65 |
66 | return {};
67 | };
68 |
--------------------------------------------------------------------------------
/src/service/payment/payment.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, describe } from "@jest/globals";
2 | import { Injector } from "../../injector.js";
3 | import { nanoid } from "nanoid";
4 | import { PaymentService } from "./index.js";
5 | import { AuthenticationService } from "../authentication/index.js";
6 | import { UserRegisterResponse } from "../authentication/models.js";
7 | import { AccountType, gatewayStripe } from "./models.js";
8 | // import Stripe from "stripe";
9 |
10 | describe("Check balance and payment history", () => {
11 | var ps: PaymentService;
12 | var auth: AuthenticationService;
13 | test("setup", async () => {
14 | let namespace = "t_" + nanoid().slice(0, 7);
15 |
16 | let i = new Injector([PaymentService]);
17 | ps = await i.getInstance("PaymentService", namespace);
18 | ps.skipStripe = true;
19 | auth = await i.getInstance("AuthenticationService", namespace);
20 | });
21 |
22 | test("payment service was initiated", async () => {
23 | expect(ps).toBeTruthy();
24 | expect(auth).toBeTruthy();
25 | });
26 |
27 | let adminToken;
28 | test("get a token", async () => {
29 | let rsp = await auth.tokenAdminGet({});
30 | adminToken = rsp.token.token;
31 | expect(adminToken.length > 1).toBe(true);
32 | });
33 |
34 | test("pay works", async () => {
35 | await ps.topup({
36 | amount: 100,
37 | token: adminToken,
38 | stripeToken: "fake stripe",
39 | currency: "usd",
40 | });
41 | });
42 |
43 | test("balance works", async () => {
44 | let rsp = await ps.balance({
45 | token: adminToken,
46 | });
47 | expect(rsp.balance).toBe(100);
48 | });
49 |
50 | test("payment history works", async () => {
51 | let rsp = await ps.charges({
52 | token: adminToken,
53 | });
54 | expect(rsp.charges.length).toBe(1);
55 | });
56 |
57 | test("pay works 2", async () => {
58 | await ps.topup({
59 | amount: 200,
60 | token: adminToken,
61 | stripeToken: "fake stripe",
62 | currency: "usd",
63 | });
64 | });
65 |
66 | test("balance works 2", async () => {
67 | let rsp = await ps.balance({
68 | token: adminToken,
69 | });
70 | expect(rsp.balance).toBe(300);
71 | });
72 |
73 | test("payment history works 2", async () => {
74 | let rsp = await ps.charges({
75 | token: adminToken,
76 | });
77 | expect(rsp.charges.length).toBe(2);
78 | });
79 |
80 | let userRegRsp: UserRegisterResponse;
81 | let user2Token;
82 | test("register simple user", async () => {
83 | userRegRsp = await auth.userRegister({
84 | user: {
85 | contacts: [{ url: "test-2@test.com" }],
86 | fullName: "Simple User Janey Jane",
87 | },
88 | password: "1011",
89 | });
90 | user2Token = userRegRsp.token.token;
91 | });
92 |
93 | test("pay works 3", async () => {
94 | await ps.topup({
95 | amount: 200,
96 | token: user2Token,
97 | stripeToken: "fake stripe",
98 | currency: "usd",
99 | });
100 | });
101 |
102 | test("user balance increased to 200 after a 200 topup", async () => {
103 | let rsp = await ps.balance({
104 | token: user2Token,
105 | });
106 | expect(rsp.balance).toBe(200);
107 | });
108 |
109 | test("charge history is correct after topup", async () => {
110 | let rsp = await ps.charges({
111 | token: user2Token,
112 | });
113 | expect(rsp.charges.length).toBe(1);
114 | });
115 |
116 | test("company internal balance should be 0 before payment", async () => {
117 | let rsp = await ps.systemBalance({
118 | token: adminToken,
119 | gatewayId: gatewayStripe.id,
120 | type: AccountType.Internal,
121 | });
122 | expect(rsp.balance).toBe(0);
123 | });
124 |
125 | test("pay with balance", async () => {
126 | await ps.payWithBalance({
127 | amount: 100,
128 | token: user2Token,
129 | currency: "usd",
130 | itemId: "test-item-1",
131 | });
132 | });
133 |
134 | test("user 2 balance decreased by 100", async () => {
135 | let rsp = await ps.balance({
136 | token: user2Token,
137 | });
138 | expect(rsp.balance).toBe(100);
139 | });
140 |
141 | test("user 1 balance is intact", async () => {
142 | let rsp = await ps.balance({
143 | token: adminToken,
144 | });
145 | expect(rsp.balance).toBe(300);
146 | });
147 |
148 | test("company internal balance increased from 0 to 100", async () => {
149 | let rsp = await ps.systemBalance({
150 | token: adminToken,
151 | gatewayId: gatewayStripe.id,
152 | type: AccountType.Internal,
153 | });
154 | expect(rsp.balance).toBe(100);
155 | });
156 | });
157 |
--------------------------------------------------------------------------------
/src/service/payment/systemBalance.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { SystemBalanceRequest, SystemBalanceResponse } from "./models.js";
3 |
4 | import { AuthenticationService } from "../authentication/index.js";
5 | import { roleAdmin } from "../authentication/models.js";
6 | import { error } from "../../util.js";
7 |
8 | export default async (
9 | connection: DataSource,
10 | auth: AuthenticationService,
11 | req: SystemBalanceRequest
12 | ): Promise => {
13 | let token = await auth.tokenRead({
14 | token: req.token,
15 | });
16 | if (!token.token.user.roles.find((r) => r.id === roleAdmin.id)) {
17 | throw error("not authorized", 401);
18 | }
19 |
20 | let result = await connection.query(
21 | `select sum(credit) - sum(debit) as balance from
22 | transaction_entry te
23 | inner join account a on a.id = te."accountId"
24 | and a."gatewayId" = $1
25 | and a.type = $2
26 | and a."userId" is null
27 | and a."organizationId" is null`,
28 | [req.gatewayId, req.type]
29 | );
30 |
31 | return {
32 | balance: parseFloat(result[0].balance) || 0,
33 | };
34 | };
35 |
--------------------------------------------------------------------------------
/src/service/payment/topup.ts:
--------------------------------------------------------------------------------
1 | import { DataSource } from "typeorm";
2 | import { error } from "../../util.js";
3 | import {
4 | TopupRequest,
5 | TopupResponse,
6 | Charge,
7 | gatewayStripe,
8 | Account,
9 | AccountType,
10 | Transaction,
11 | TransactionEntry,
12 | Card,
13 | } from "./models.js";
14 | import { nanoid } from "nanoid";
15 | import Stripe from "stripe";
16 | import { AuthenticationService } from "../authentication/index.js";
17 | import { platformEmail } from "../authentication/models.js";
18 |
19 | export default async (
20 | connection: DataSource,
21 | auth: AuthenticationService,
22 | req: TopupRequest,
23 | apiKey: string,
24 | skipStripe: boolean
25 | ): Promise => {
26 | if (!skipStripe && !apiKey) {
27 | throw error("payment provider api key missing", 400);
28 | }
29 |
30 | let token = await auth.tokenRead({
31 | token: req.token,
32 | });
33 |
34 | if (!req.amount || req.amount <= 0) {
35 | throw error("amount missing", 400);
36 | }
37 | if (!req.token) {
38 | throw error("token missing", 400);
39 | }
40 | if (!req.currency) {
41 | throw error("currency missing", 400);
42 | }
43 |
44 | let internalUserAccount;
45 | let externalUserAccount;
46 | await connection.transaction(async (tran) => {
47 | let userAccounts = await connection
48 | .createQueryBuilder(Account, "account")
49 | .where(`account."userId" = :userId`, { userId: token.token.userId })
50 | .getMany();
51 |
52 | if (!userAccounts.find((a) => a.type === AccountType.Internal)) {
53 | let newAccount = new Account();
54 | newAccount.id = nanoid();
55 | newAccount.userId = token.token.userId;
56 | newAccount.type = AccountType.Internal;
57 | newAccount.gatewayId = gatewayStripe.id;
58 | await tran.save(newAccount);
59 | internalUserAccount = newAccount;
60 | } else {
61 | internalUserAccount = userAccounts.find(
62 | (a) => a.type === AccountType.Internal
63 | );
64 | }
65 | if (!userAccounts.find((a) => a.type === AccountType.External)) {
66 | let newAccount = new Account();
67 | newAccount.id = nanoid();
68 | newAccount.userId = token.token.userId;
69 | newAccount.type = AccountType.External;
70 | await tran.save(newAccount);
71 | externalUserAccount = newAccount;
72 | } else {
73 | externalUserAccount = userAccounts.find(
74 | (a) => a.type === AccountType.External
75 | );
76 | }
77 | });
78 | if (!externalUserAccount) {
79 | throw error("external user account not found", 500);
80 | }
81 | if (!internalUserAccount) {
82 | throw error("internal user account not found", 500);
83 | }
84 |
85 | let charge: Stripe.Response;
86 | if (req.saveCard) {
87 | throw error("not implemented", 500);
88 | let stripe = new Stripe(apiKey, { apiVersion: "2022-11-15" });
89 | let customer = await stripe.customers.create({
90 | source: req.stripeToken,
91 | email: token.token.user.contacts.find(
92 | (c) => c.platformId == platformEmail.id
93 | ).url,
94 | name: token.token.user.fullName,
95 | });
96 | let stripeCard = await stripe.customers.retrieveSource(
97 | customer.id,
98 | customer.default_source as string
99 | );
100 | let card: Card = new Card();
101 | card.id = nanoid();
102 | card.userId = token.token.userId;
103 | card.gatewayId = gatewayStripe.id;
104 | card.gatewayChargableId = customer.id;
105 | console.log(stripeCard);
106 | // @todo crufter: finish
107 | } else {
108 | if (!skipStripe) {
109 | try {
110 | var stripe = new Stripe(apiKey, { apiVersion: "2022-11-15" });
111 |
112 | charge = await stripe.charges.create({
113 | source: req.stripeToken,
114 | currency: req.currency,
115 | amount: req.amount,
116 | capture: true,
117 | });
118 | } catch (err) {
119 | if (err) {
120 | console.log("Problem charging customer: ", err);
121 | throw error(JSON.stringify(err), 500);
122 | }
123 | }
124 | } else {
125 | charge = {
126 | id: nanoid(),
127 | } as Stripe.Response;
128 | }
129 | }
130 |
131 | const newCharge: Charge = new Charge();
132 | newCharge.id = nanoid();
133 | newCharge.userId = token.token.userId;
134 | newCharge.amount = req.amount;
135 | newCharge.currency = req.currency;
136 | newCharge.description = "Top up";
137 | newCharge.gatewayChargeId = charge.id;
138 | newCharge.gatewayResponse = charge;
139 | newCharge.gatewayId = gatewayStripe.id;
140 |
141 | let transaction = new Transaction();
142 | transaction.id = nanoid();
143 | transaction.chargeId = newCharge.id;
144 |
145 | let externalUserTransactionEntry = new TransactionEntry();
146 | externalUserTransactionEntry.id = nanoid();
147 | externalUserTransactionEntry.accountId = externalUserAccount.id;
148 | externalUserTransactionEntry.debit = req.amount;
149 | externalUserTransactionEntry.credit = 0;
150 |
151 | let internalUserTransactionEntry = new TransactionEntry();
152 | internalUserTransactionEntry.id = nanoid();
153 | internalUserTransactionEntry.accountId = internalUserAccount.id;
154 | internalUserTransactionEntry.debit = 0;
155 | internalUserTransactionEntry.credit = req.amount;
156 |
157 | // @todo add transaction for stripe account where the
158 | // fees go. not sure how to get the fees yet
159 |
160 | await connection.transaction(async (tran) => {
161 | await tran.save(newCharge);
162 | await tran.save(transaction);
163 | await tran.save(externalUserTransactionEntry);
164 | await tran.save(internalUserTransactionEntry);
165 | });
166 |
167 | return {};
168 | };
169 |
--------------------------------------------------------------------------------
/src/service/system/README.md:
--------------------------------------------------------------------------------
1 | [<- Back to Getting Started](../../../docs/README.md)
2 | # System service
3 |
4 | The system service is designed to give information about the running services, their types, the nodes to enable building tooling on top.
5 |
6 | It is essentially runtime reflection for Actio.
7 |
8 | ## Endpoints
9 |
10 | ### nodesRead
11 |
12 | Returns the list of nodes and the services they are running.
13 |
14 | ```sh
15 | $ curl 127.0.0.1:8080/SystemService/nodesRead
16 | {
17 | "nodes":[
18 | {
19 | "services":[
20 | {
21 | "name":"SystemService"
22 | },
23 | {
24 | "name":"AuthenticationService"
25 | },
26 | {
27 | "name":"ConfigService"
28 | },
29 | {
30 | "name":"Function"
31 | }
32 | ]
33 | }
34 | ]
35 | }
36 | ```
37 |
38 | When there are addresses set it returns the addresses too - both for nodes and their services.
39 |
40 | ### apiRead
41 |
42 | apiRead returns the type information for endpoints and there parameters. Here is information related to the `SystemService` `nodesRead` endpoint:
43 |
44 | ```sh
45 | {
46 | "services":{
47 | "SystemService":{
48 | "nodesRead":{
49 | "info":{
50 | "methodName":"nodesRead",
51 | "paramNames":[
52 | "req"
53 | ],
54 | "paramTypes":[
55 | "NodesReadRequest"
56 | ],
57 | "options":{
58 | "returns":"NodesReadResponse"
59 | }
60 | },
61 | "paramOptions":[
62 | {
63 |
64 | }
65 | ]
66 | }
67 | }
68 | },
69 | "types":{
70 | "NodesReadRequest":{
71 | "propagate":{
72 | "data":{
73 | "type":"Boolean",
74 | "name":"propagate"
75 | }
76 | }
77 | },
78 | "NodesReadResponse":{
79 | "nodes":{
80 | "data":{
81 | "hint":"Node",
82 | "type":"Array",
83 | "name":"nodes"
84 | }
85 | }
86 | },
87 | "Node":{
88 | "id":{
89 | "data":{
90 | "type":"String",
91 | "name":"id"
92 | }
93 | },
94 | "address":{
95 | "data":{
96 | "type":"String",
97 | "name":"address"
98 | }
99 | },
100 | "services":{
101 | "data":{
102 | "hint":"Service",
103 | "type":"Array",
104 | "name":"services"
105 | }
106 | }
107 | },
108 | "Service":{
109 | "address":{
110 | "data":{
111 | "type":"String",
112 | "name":"address"
113 | }
114 | },
115 | "name":{
116 | "data":{
117 | "type":"String",
118 | "name":"name"
119 | }
120 | }
121 | }
122 | }
123 | }
124 | ```
125 |
126 | ### Conventions
127 |
128 | Please note that types must be annotated with the `@Field` decorator for them to be visible to the runtime.
129 |
130 | Endpoints must be decorated with the `@Endpoint` decorator and the return value specified with the `returns` option. The reason for this is that higher order types are currently invisible for TypeScript's reflection, so it needs quiet a bit of handholding to get a complete picture about types.
131 |
132 | Similarly, unnamed types like `{[key: string]: any}` are at the moment invisible to Actio and a solution must be found to properly return them in `nodesRead`.
133 |
134 | As usual, the [models.ts](./models.ts) contains types for this service.
--------------------------------------------------------------------------------
/src/service/system/apiRead.ts:
--------------------------------------------------------------------------------
1 | import { getAPIJSON } from "../../reflect.api.js";
2 |
3 | export default async (request: any): Promise => {
4 | return getAPIJSON();
5 | };
6 |
--------------------------------------------------------------------------------
/src/service/system/index.ts:
--------------------------------------------------------------------------------
1 | import { Endpoint, Service } from "../../reflect.js";
2 |
3 | import apiRead from "./apiRead.js";
4 | import nodesRead from "./nodesRead.js";
5 | import { NodesReadRequest, NodesReadResponse } from "./models.js";
6 |
7 | import { Servicelike } from "../../util.js";
8 | import { Injector } from "../../injector.js";
9 |
10 | interface ApiReadRequest {}
11 |
12 | @Service()
13 | export class SystemService implements Servicelike {
14 | meta = {
15 | name: "system",
16 | };
17 |
18 | private injector: Injector;
19 |
20 | constructor(injector: Injector) {
21 | this.injector = injector;
22 | }
23 |
24 | apiRead(req: ApiReadRequest) {
25 | return apiRead(req);
26 | }
27 |
28 | @Endpoint({
29 | returns: NodesReadResponse,
30 | })
31 | async nodesRead(req: NodesReadRequest) {
32 | return nodesRead(this.injector, req);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/service/system/models.ts:
--------------------------------------------------------------------------------
1 | import { Field } from "../../reflect.field.js";
2 |
3 | export class NodesReadRequest {
4 | @Field()
5 | propagate: boolean;
6 | }
7 |
8 | export class NodesReadResponse {
9 | @Field({ hint: () => Node })
10 | nodes: Node[];
11 | }
12 |
13 | export class Node {
14 | @Field()
15 | id?: string;
16 |
17 | @Field()
18 | address: string;
19 |
20 | @Field({ hint: () => Service})
21 | services: Service[];
22 | }
23 |
24 | export class Service {
25 | @Field()
26 | address: string;
27 |
28 | @Field()
29 | name: string;
30 | }
31 |
--------------------------------------------------------------------------------
/src/service/system/nodes.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@jest/globals";
2 |
3 | import { createApp } from "../../registrator.js";
4 | import { Service } from "../../reflect.js";
5 | import { default as request } from "supertest";
6 | import { SystemService } from "./index.js";
7 | import _ from "lodash";
8 |
9 | @Service()
10 | class NodeA {
11 | constructor() {}
12 | }
13 |
14 | @Service()
15 | class NodeB {
16 | constructor(a: NodeA) {}
17 | }
18 |
19 | @Service()
20 | class NodeC {
21 | constructor(b: NodeB) {}
22 | }
23 |
24 | test("node read", async () => {
25 | let randomPortNumberA = Math.floor(Math.random() * 10000) + 10000;
26 | let randomPortNumberB = Math.floor(Math.random() * 10000) + 10000;
27 | let randomPortNumberC = Math.floor(Math.random() * 10000) + 10000;
28 |
29 | // pass in node C because then it pulls in the other two services
30 | let appC = createApp([NodeC, SystemService], {
31 | addresses: new Map()
32 | .set("NodeA", `http://localhost:${randomPortNumberA}`)
33 | .set("NodeB", `http://localhost:${randomPortNumberB}`),
34 | nodeID: "1",
35 | });
36 |
37 | let appB = createApp([NodeC, SystemService], {
38 | addresses: new Map()
39 | .set("NodeA", `http://localhost:${randomPortNumberA}`)
40 | .set("NodeC", `http://localhost:${randomPortNumberC}`),
41 | nodeID: "2",
42 | });
43 |
44 | let appA = createApp([NodeC, SystemService], {
45 | addresses: new Map()
46 | .set("NodeB", `http://localhost:${randomPortNumberB}`)
47 | .set("NodeC", `http://localhost:${randomPortNumberC}`),
48 | nodeID: "3",
49 | selfAddress: `http://localhost:${randomPortNumberA}`,
50 | });
51 |
52 | let serverA, serverB, serverC;
53 | setTimeout(() => {
54 | serverA = appA.listen(randomPortNumberA);
55 | }, 1);
56 | setTimeout(() => {
57 | serverB = appB.listen(randomPortNumberB);
58 | }, 1);
59 | setTimeout(() => {
60 | serverC = appC.listen(randomPortNumberC);
61 | }, 1);
62 |
63 | let response = await request(appA).post("/SystemService/nodesRead").send({
64 | propagate: true,
65 | });
66 | expect(response.status).toBe(200);
67 | expect({
68 | nodes: response.body.nodes.sort((a, b) => {
69 | if (a.id < b.id) return -1;
70 | if (a.id > b.id) return 1;
71 | return 0;
72 | }),
73 | }).toEqual({
74 | nodes: [
75 | {
76 | id: "1",
77 | address: `http://localhost:${randomPortNumberC}`,
78 | services: [
79 | {
80 | name: "NodeC",
81 | },
82 | {
83 | name: "SystemService",
84 | },
85 | {
86 | address: `http://localhost:${randomPortNumberB}`,
87 | name: "NodeB",
88 | },
89 | {
90 | address: `http://localhost:${randomPortNumberA}`,
91 | name: "NodeA",
92 | },
93 | ],
94 | },
95 | {
96 | id: "2",
97 | address: `http://localhost:${randomPortNumberB}`,
98 | services: [
99 | {
100 | address: `http://localhost:${randomPortNumberC}`,
101 | name: "NodeC",
102 | },
103 | {
104 | name: "SystemService",
105 | },
106 | {
107 | name: "NodeB",
108 | },
109 | {
110 | address: `http://localhost:${randomPortNumberA}`,
111 | name: "NodeA",
112 | },
113 | ],
114 | },
115 | {
116 | id: "3",
117 | address: `http://localhost:${randomPortNumberA}`,
118 | services: [
119 | {
120 | address: `http://localhost:${randomPortNumberC}`,
121 | name: "NodeC",
122 | },
123 | {
124 | name: "SystemService",
125 | },
126 | {
127 | address: `http://localhost:${randomPortNumberB}`,
128 | name: "NodeB",
129 | },
130 | {
131 | name: "NodeA",
132 | },
133 | ],
134 | },
135 | ],
136 | });
137 |
138 | serverA.close();
139 | serverB.close();
140 | serverC.close();
141 | });
142 |
--------------------------------------------------------------------------------
/src/service/system/nodesRead.ts:
--------------------------------------------------------------------------------
1 | import { Injector, toSnakeCase } from "../../injector.js";
2 | import { env } from "../../env.js";
3 | import _ from "lodash";
4 | import { NodesReadRequest, NodesReadResponse } from "./models.js";
5 |
6 | export default async (
7 | injector: Injector,
8 | request: NodesReadRequest
9 | ): Promise => {
10 | let addresses = new Map();
11 | let rsp: NodesReadResponse = {
12 | nodes: [],
13 | };
14 | let node = {
15 | id: injector.nodeID,
16 | address: env.selfAddress || injector.selfAddress,
17 | services: [],
18 | };
19 |
20 | injector.availableClassNames().forEach((className) => {
21 | let address =
22 | injector.addresses.get(className) ||
23 | injector.addresses.get(toSnakeCase(className)) ||
24 | process.env[className] ||
25 | process.env[toSnakeCase(className)];
26 | if (address) {
27 | addresses.set(className, address);
28 | node.services.push({
29 | name: className,
30 | address: address,
31 | });
32 | } else {
33 | node.services.push({
34 | name: className,
35 | });
36 | }
37 | });
38 | rsp.nodes.push(node);
39 |
40 | if (request.propagate) {
41 | await Promise.all(
42 | _.map(Array.from(addresses.values()), async (address) => {
43 | let resp: NodesReadResponse = (await injector.serviceCall(
44 | address,
45 | "System",
46 | "nodesRead",
47 | []
48 | )) as NodesReadResponse;
49 | resp.nodes.forEach((n) => {
50 | n.address = address;
51 | rsp.nodes.push(n);
52 | });
53 | })
54 | );
55 | }
56 |
57 | return rsp;
58 | };
59 |
--------------------------------------------------------------------------------
/src/typeorm.ts:
--------------------------------------------------------------------------------
1 | import { Connector } from "./db.js";
2 | import { getMeta } from "./reflect.js";
3 | import { DataSource } from "typeorm";
4 | import { MixedList, EntitySchema } from "typeorm";
5 |
6 | /**
7 | * Typeorm related configuration for services that use typeorm,
8 | * ie. have DataSource as a dependency.
9 | */
10 | export interface TypeORMMeta {
11 | /**
12 | * Call DataSource.syncronize() when the service starts?
13 | * Defaults to true.
14 | */
15 | syncronize?: boolean;
16 | /**
17 | * All typeorm entities that are used inside a service.
18 | */
19 | entities: MixedList>;
20 | }
21 |
22 | /**
23 | * Each service has its own database in postgres,
24 | * so databases are both namespace and service specific,
25 | * ie. the database name is something like domain + serviceName,
26 | * or an actual example: "example-com--authentication" (this
27 | * is just to explain, the actual database name should not
28 | * matter for you, just assume you have your own database for your
29 | * service, in your own namespace).
30 | */
31 | export class TypeORMHandler {
32 | public typeName = "DataSource";
33 | private connectorsByService: Map = new Map();
34 |
35 | constructor() {}
36 |
37 | async handle(serviceClass: any, config: any): Promise {
38 | let namespace = config as string;
39 | // We need to acquire a per service connection
40 | // because we need to react to the typeormDbCreated callback
41 | // on a per service case - we want to call the service _onFirstInit
42 | // method when it happens.
43 | //
44 | // @todo this is not true anymore as _onFirstInit was retired
45 |
46 | if (!this.connectorsByService.has(serviceClass.name)) {
47 | let conn = new Connector({
48 | typeormDbCreated: (conn) => {
49 | // @todo this was left over here from the
50 | // time where we had _onFirstInit, we should
51 | // decide if we want to bring it back or not.
52 | // firstConnectionForThisService = true;
53 | },
54 | });
55 | this.connectorsByService.set(serviceClass.name, conn);
56 | }
57 | let conn = this.connectorsByService.get(serviceClass.name);
58 |
59 | // This assumes the constructor won't panic because the dependencies
60 | // are not supplied.
61 | let meta = getMeta(serviceClass);
62 | if (!meta) {
63 | throw new Error(
64 | `Service ${serviceClass.name} has no metadata, did you forget to decorate it with @Service()?`
65 | );
66 | }
67 | if (!(meta as any).typeorm) {
68 | throw new Error(
69 | `Service ${serviceClass.name} has no typeorm metadata, did you forget to decorate it with @Service()?`
70 | );
71 | }
72 | let conf = (meta as any).typeorm as TypeORMMeta;
73 | let connection = await conn.connect(
74 | namespace + "__" + serviceClass.name.toLowerCase().replace("service", ""),
75 | conf?.entities
76 | );
77 | return connection;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
1 | export class Error {
2 | /** Error message */
3 | message: string;
4 | /** HTTP code */
5 | status: number;
6 |
7 | constructor(message: string, status: number) {
8 | this.message = message;
9 | this.status = status;
10 | }
11 | }
12 |
13 | export function error(message: string, status?: number): Error {
14 | return new Error(message, status);
15 | }
16 |
17 | export function copy(from, to) {
18 | if (!from || !to) {
19 | return;
20 | }
21 | for (const [key, value] of Object.entries(from)) {
22 | to[key] = value;
23 | }
24 | }
25 |
26 | export interface ServiceMeta {
27 | name?: string;
28 | }
29 |
30 | /**
31 | * Servicelike is an interface that is used to describe a service.
32 | * Services should not panic if they are not supplied with all the dependencies
33 | * at the time of construction.
34 | *
35 | * The endpoints of a service are its method:
36 | * - Methods annotated with the `@Raw()` decorator will be registered as
37 | * direct http methods and have access to http request and response types.
38 | * A prime example of this is a file upload.
39 | * - Method names starting with underscore are callbacks to specific events,
40 | * see examples below.
41 | * - All other methods will only have access to their JSON request data.
42 | * This is favorable to raw http methods as it is cleaner, enables testing etc.
43 | *
44 | */
45 | export interface Servicelike {
46 | /**
47 | * Meta contains metadata about the service.
48 | * This can range from registration name to dependency config -
49 | * like entities for the `TypeORMHandler`.
50 | */
51 | meta?: ServiceMeta;
52 |
53 | /**
54 | * Called when a service is initialized.
55 | * Typically this happens when the server starts up.
56 | */
57 | _onInit?: () => Promise;
58 | }
59 |
60 | /**
61 | * Configurable contains config options all service
62 | * configs should ideally implement.
63 | */
64 | export interface Configurable {
65 | skipSeed?: boolean;
66 | }
67 |
--------------------------------------------------------------------------------
/tsconfig.dev.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [".eslintrc.js"]
3 | }
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "esnext",
4 | "noImplicitReturns": false,
5 | "noUnusedLocals": true,
6 | "outDir": "lib",
7 | "sourceMap": true,
8 | //"strictNullChecks": true,
9 | //"strict": true,
10 | "target": "esnext",
11 | "experimentalDecorators": true,
12 | "emitDecoratorMetadata": true,
13 | "typeRoots": ["./node_modules/@types"],
14 | "declaration": true,
15 | //"lib": ["esnext"],
16 | "esModuleInterop": true,
17 | "moduleResolution": "nodenext"
18 | },
19 | "compileOnSave": true,
20 | "include": ["src"],
21 | "exclude": ["node_modules", "lib"],
22 | "allowSyntheticDefaultImports": true
23 | }
24 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------