├── .eslintrc.json
├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── catalogist.yml
│ └── codeql-analysis.yml
├── .gitignore
├── .husky
└── pre-commit
├── .npmrc
├── .prettierrc.json
├── CODEOWNERS
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── SECURITY.md
├── api
└── schema.yml
├── arkit.json
├── diagrams
└── catalogist-diagram.drawio
├── images
├── arkit.svg
├── catalogist-diagram.png
└── servicemap.jpeg
├── package-lock.json
├── package.json
├── serverless.yml
├── src
├── application
│ └── errors
│ │ ├── GetDataError.ts
│ │ ├── MissingSpecKeysError.ts
│ │ ├── SizeError.ts
│ │ ├── UpdateItemError.ts
│ │ └── ValidationError.ts
├── domain
│ ├── entities
│ │ └── Catalogist.ts
│ └── valueObjects
│ │ └── Manifest.ts
├── infrastructure
│ ├── adapters
│ │ └── web
│ │ │ ├── CreateRecord.ts
│ │ │ └── GetRecord.ts
│ ├── authorizers
│ │ └── Authorizer.ts
│ ├── frameworks
│ │ ├── getQueryStringParams.ts
│ │ └── isJsonString.ts
│ └── repositories
│ │ ├── DynamoDbRepo.ts
│ │ └── LocalRepo.ts
├── interfaces
│ ├── Catalogist.ts
│ ├── Manifest.ts
│ ├── QueryStringParams.ts
│ └── Repository.ts
└── usecases
│ ├── createRecord.ts
│ └── getRecord.ts
├── testdata
├── TestDatabase.ts
└── requests
│ ├── awsEventRequest.json
│ ├── createRecordMissingServiceNameField.json
│ ├── createRecordMissingSpecField.json
│ ├── createRecordValidBasic.json
│ ├── createRecordValidFull.json
│ └── createRecordValidUnknownFields.json
├── tests
├── unit
│ ├── getQueryStringParams.test.ts
│ └── isJsonString.test.ts
└── usecases
│ ├── createRecord.test.ts
│ └── getRecord.test.ts
├── tsconfig.json
└── vitest.config.ts
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["@typescript-eslint"],
3 | "parserOptions": {
4 | "ecmaVersion": 2020,
5 | "sourceType": "module"
6 | },
7 | "extends": [
8 | "plugin:@typescript-eslint/recommended",
9 | "plugin:prettier/recommended"
10 | ],
11 | "rules": {
12 | //"no-unused-vars": "off",
13 | "@typescript-eslint/no-explicit-any": ["off"],
14 | "@typescript-eslint/ban-ts-comment": ["warn"],
15 | "complexity": ["warn", { "max": 9 }]
16 | },
17 | "env": {
18 | "node": true,
19 | "es6": true
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: mikaelvesavuori
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/catalogist.yml:
--------------------------------------------------------------------------------
1 | name: catalogist
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 |
8 | permissions:
9 | actions: read
10 |
11 | jobs:
12 | build-unix:
13 | runs-on: ${{ matrix.os }}
14 | strategy:
15 | matrix:
16 | os: ['ubuntu-latest']
17 | node-version: [22.x]
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v4
21 |
22 | - name: Setup Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 |
27 | - name: Cache dependencies
28 | uses: actions/cache@v4
29 | with:
30 | path: ~/.npm
31 | key: npm-${{ hashFiles('package-lock.json') }}
32 | restore-keys: npm-
33 |
34 | - name: Install dependencies
35 | run: |
36 | npm ci --ignore-scripts --force
37 |
38 | - name: Test
39 | run: npm test
40 |
41 | - name: Codecov
42 | uses: codecov/codecov-action@v4
43 |
44 | - name: Send coverage report with Code Climate
45 | uses: paambaati/codeclimate-action@v9.0.0
46 | env:
47 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }}
48 | with:
49 | coverageLocations: ${{ github.workspace }}/coverage/lcov.info:lcov
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ main ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ main ]
20 | schedule:
21 | - cron: '21 18 * * 1'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support
38 |
39 | steps:
40 | - name: Checkout repository
41 | uses: actions/checkout@v2
42 |
43 | # Initializes the CodeQL tools for scanning.
44 | - name: Initialize CodeQL
45 | uses: github/codeql-action/init@v1
46 | with:
47 | languages: ${{ matrix.language }}
48 | # If you wish to specify custom queries, you can do so here or in a config file.
49 | # By default, queries listed here will override any specified in a config file.
50 | # Prefix the list here with "+" to use these queries and those in the config file.
51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
52 |
53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
54 | # If this step fails, then you should remove it and run the build manually (see below)
55 | - name: Autobuild
56 | uses: github/codeql-action/autobuild@v1
57 |
58 | # ℹ️ Command-line programs to run using the OS shell.
59 | # 📚 https://git.io/JvXDl
60 |
61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
62 | # and modify them (or add more) to build your code if your project
63 | # uses a compiled language
64 |
65 | #- run: |
66 | # make bootstrap
67 | # make release
68 |
69 | - name: Perform CodeQL Analysis
70 | uses: github/codeql-action/analyze@v1
71 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .serverless/
3 | .webpack/
4 | build/
5 | .dccache
6 | .esbuild/
7 | lib/
8 |
9 | # Files generated from tests
10 | coverage/
11 | jest-coverage/
12 | **/jest-coverage/**
13 |
14 | # No Yarn stuff
15 | yarn.lock
16 |
17 | ### Node ###
18 |
19 | # Reports
20 | *.report.html
21 | *.report.csv
22 | *.report.json
23 | hint-report/
24 |
25 | # Dependency directories
26 | /**/node_modules
27 |
28 | # Environment variables file(s)
29 | .env
30 | .env.*
31 |
32 | # Compressed archives
33 | *.7zip
34 | *.rar
35 | *.zip
36 |
37 | ### VisualStudioCode ###
38 | .vscode/*
39 | !.vscode/settings.json
40 | !.vscode/tasks.json
41 | !.vscode/launch.json
42 | !.vscode/extensions.json
43 |
44 | ### Vim ###
45 | *.sw[a-p]
46 |
47 | # Windows thumbnail cache files
48 | Thumbs.db
49 | ehthumbs.db
50 | ehthumbs_vista.db
51 |
52 | # Folder config file
53 | Desktop.ini
54 |
55 | # Recycle Bin used on file shares
56 | $RECYCLE.BIN/
57 |
58 | ### macOS ###
59 | *.DS_Store
60 | .AppleDouble
61 | .LSOverride
62 |
63 | # Icon must end with two \r
64 | Icon
65 |
66 | # Thumbnails
67 | ._*
68 |
69 | # Files that might appear in the root of a volume
70 | .DocumentRevisions-V100
71 | .fseventsd
72 | .Spotlight-V100
73 | .TemporaryItems
74 | .Trashes
75 | .VolumeIcon.icns
76 | .com.apple.timemachine.donotpresent
77 |
78 | # Directories potentially created on remote AFP share
79 | .AppleDB
80 | .AppleDesktop
81 | Network Trash Folder
82 | Temporary Items
83 | .apdisk
84 |
85 | ### Node ###
86 | # Logs
87 | logs
88 | *.log
89 | npm-debug.log*
90 | yarn-debug.log*
91 | yarn-error.log*
92 |
93 | # Runtime data
94 | pids
95 | *.pid
96 | *.seed
97 | *.pid.lock
98 |
99 | # Directory for instrumented libs generated by jscoverage/JSCover
100 | lib-cov
101 |
102 | # Coverage directory used by tools like istanbul
103 | coverage
104 |
105 | # Optional npm cache directory
106 | .npm
107 |
108 | # Optional eslint cache
109 | .eslintcache
110 |
111 | # Optional REPL history
112 | .node_repl_history
113 |
114 | # Output of 'npm pack'
115 | *.tgz
116 |
117 | # Yarn Integrity file
118 | .yarn-integrity
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm test
2 | npm run licenses
3 | npm run lint
4 | npx arkit
5 | git add .
6 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "printWidth": 100,
4 | "tabWidth": 2,
5 | "singleQuote": true,
6 | "trailingComma": "none"
7 | }
8 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @mikaelvesavuori
2 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | [INSERT CONTACT METHOD].
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][mozilla coc].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [mozilla coc]: https://github.com/mozilla/diversity
131 | [faq]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # General guidelines for contributing code
2 |
3 | You are very welcome to contribute to the project! Pull requests welcome, as well as issues or plain messages.
4 |
5 | Respect the below and I will be happy to merge your work and credit you for it.
6 |
7 | ## Style and structure
8 |
9 | - Follow the style and conventions already in place. This project uses Typescript and attempts to do some sort of [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html).
10 | - _As always, write clean, easy-to-read code. Prefer being slightly more verbose and semantic than being "efficient" and terse_, if such a choice is necessary. Write as if someone is going to contribute on top of your code base tomorrow without knowing you or your work.
11 | - Follow importing by highest-level components at top, and detail-oriented components at bottom. Example:
12 |
13 | ```
14 | import { APIGatewayProxyEventQueryStringParameters } from 'aws-lambda';
15 |
16 | import { QueryStringParams } from '../domain/interfaces/QueryStringParams';
17 |
18 | import { UnknownKeyError } from '../domain/errors/UnknownKeyError';
19 | ```
20 |
21 | 1. External imports
22 | 2. Entities
23 | 3. Contracts
24 | 4. Interfaces
25 | 5. Use cases or other high-level files
26 | 6. Same-level files
27 | 7. Frameworks
28 | 8. Messages, errors, warnings (separated)
29 |
30 | ## Tests
31 |
32 | - **Always include tests for additions or changes**. Aim for 100% coverage, but set a minimum bar to cover at least the main functionality. We should ideally have total code coverage of 95% or more. Your contribution will affect that score, so aim to keep it high(er)! :)
33 | - It's encouraged to place any test data in the `testdata` folder.
34 | - **Always check that all tests (including your new ones) are passing before making a pull request**.
35 |
36 | ## Error handling
37 |
38 | - Make sure to handle errors and do any relevant validation logic. Also always output meaningful, actionable messages/warnings/errors to the user.
39 | - Avoid inlining messages, errors or warnings. Instead place those in the dedicated files for each of the mentioned concerns, and read them from there.
40 |
41 | ## Documentation
42 |
43 | - Document your code with JSDoc at the start of your code. Since the project uses Typescript, add "description" documentation; more than that is not needed.
44 | - Add any inline comments as needed for anything that is not self-evident.
45 | - Update the README with any user-facing changes, such as new CLI commands or arguments.
46 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2022-present Mikael Vesavuori
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Catalogist 📚 📓 📒 📖 🔖
2 |
3 |  [](https://app.fossa.com/projects/git%2Bgithub.com%2Fmikaelvesavuori%2Fcatalogist?ref=badge_shield) [](https://sonarcloud.io/summary/new_code?id=mikaelvesavuori_catalogist) [](https://codescene.io/projects/22501) [](https://codescene.io/projects/22501) [](https://codecov.io/gh/mikaelvesavuori/catalogist) [](https://codeclimate.com/github/mikaelvesavuori/catalogist/maintainability)
4 |
5 | ## The easy way to catalog and make your software and (micro)services visible to your organization through an API.
6 |
7 | **You were a person on a mission: To have a total bird's eye view on your entire software estate. You tried to win the hearts and minds of developers with microservices, and after many battles you are now finally churning out itty-bitty services, but find yourself in a quagmire without the faintest clue about what's going on anymore. Like [Fox Mulder](https://en.wikipedia.org/wiki/Fox_Mulder), you become disillusioned with what sad excuse of a "truth" is _actually_ out there.** 😭😭😭
8 |
9 | 
10 |
11 | _From [Mario Fusco's Twitter post](https://twitter.com/mariofusco/status/1112332826861547520/photo/1)_
12 |
13 | Catalogist helps you make sense of that, in a lightweight and developer-friendly way, without having to break the bank to purchase six-figure enterprise architecture software or going all-in on [Backstage](https://backstage.io).
14 |
15 | ### How it works
16 |
17 | Simple: Write a bit of metadata description (a _manifest_ file) for every service/software in a standardized format and send it to a central service, making it available to read through an API. With no more that that, we can mitigate the lack of visibility and nomenclature around how we express the attributes of our software or services.
18 |
19 | When the manifest reaches the actual database/persistence layer, it is called a _record_ while it's there, laying dormant.
20 |
21 | An implementer will interact with Catalogist in one of two typical ways:
22 |
23 | - **Custom software (e.g. your own microservices)**: Create a _manifest_ file in the root of the application, and make a POST request to the Catalogist service during the CI stage. This is ideal since it enforces an always-up-to-date version of the solution's manifest.
24 | - **Manually, for example for non-custom (e.g. commercial-off-the-shelf") software**: The Catalogist service can be called manually or as an integrated part of a "dashboard" that you build yourself. An operations team could also do infrequent updates based on a ticketing system.
25 |
26 | ### Diagram
27 |
28 | _As it stands currently, Catalogist is implemented in an AWS-slanted direction. This should be fairly easy to modify so it works with other cloud platforms and with other persistence technologies. If there is sufficient demand, I might add extended support. Or you do it! Just make a PR and I'll see how we can proceed._
29 |
30 | On the surface Catalogist is a relatively simple Node.js-based serverless application that exposes an API Gateway with three microservices behind it: an optional authorizer, one for creating a record, and the last one for getting records. Records are persisted in DynamoDB. When deployed, the standard implementation—as provided—results in a complete solution with an (optional) authorizer function, the backend functions, and all required infrastructure resources.
31 |
32 | 
33 |
34 | Please see the [generated documentation site](https://catalogist.pages.dev) for more detailed information.
35 |
36 | ---
37 |
38 | ## Prerequisites
39 |
40 | - Amazon Web Services (AWS) account with sufficient permissions so that you can deploy infrastructure. A naive but simple policy would be full rights for CloudWatch, Lambda, API Gateway, DynamoDB, X-Ray, and S3.
41 |
42 | ## Installation
43 |
44 | Clone or fork the repo as you normally would. Run `npm install --force`.
45 |
46 | ## Commands
47 |
48 | The below commands are the most critical ones. See package.json for more commands!
49 |
50 | - `npm start`: Runs Serverless Framework in offline mode
51 | - `npm test`: Tests code
52 | - `npm run deploy`: Deploys code with Serverless Framework
53 | - `npm run build`: Package and build the code with Serverless Framework
54 | - `npm run teardown`: Removes the deployed stack
55 |
56 | ## Configuration
57 |
58 | 1. You will need to configure your own AWS account number in `serverless.yml`.
59 | 2. You should enter a self-defined API key in `serverless.yml` under `custom.config.apiKey`; otherwise a default key will be used (the value is seen in the mentioned location).
60 |
61 | ## Running Catalogist
62 |
63 | Run `npm start`.
64 |
65 | ## Deployment
66 |
67 | First make sure that you have a fallback value for your AWS account number in `serverless.yml`, for example: `awsAccountNumber: ${opt:awsAccountNumber, '123412341234'}` or that you set the deployment script to use the flag, for example `npx sls deploy --awsAccountNumber 123412341234`.
68 |
69 | Then you can deploy with `npm run deploy`.
70 |
71 | ## Setting up for CI and automation
72 |
73 | In your CI tool, just call the API, passing in your manifest file and your (self-defined) API key:
74 |
75 | ```bash
76 | curl -X POST ${ENDPOINT}/record -d "@manifest.json" -H "Authorization: ${API_KEY}"
77 | ```
78 |
79 | ## Manifest
80 |
81 | The manifest file is a simple JSON file or a JSON payload that describes your solution, system, or service.
82 |
83 | The below gives an overview of what data can be described. See the example and interface specification further down.
84 |
85 | ### Required top-level keys/fields/properties
86 |
87 | #### `spec`
88 |
89 | Fundamental information about your solution. **Note that only the `repo`, `name`, and `description` fields are required, all other properties are optional.**
90 |
91 | ### Optional top-level keys/fields/properties
92 |
93 | #### `relations`
94 |
95 | Named relations this component has to other components.
96 |
97 | #### `support`
98 |
99 | Support information for the component.
100 |
101 | #### `slo`
102 |
103 | Array of SLO items. An SLO item represents Service Level Objective (SLO) information. Max 20 items allowed.
104 |
105 | #### `api`
106 |
107 | Array of API items. An API item represents the name of any API connected to this solution. The value should ideally point to a (local or remote) schema or definition. Max 20 items allowed.
108 |
109 | #### `metadata`
110 |
111 | Any optional metadata. Accepts custom-defined keys with string values.
112 |
113 | #### `links`
114 |
115 | Array of Link items. A Link item represents a link to external resources. Max 20 items allowed.
116 |
117 | ### Full example
118 |
119 | The below gives you an idea of how a "full-scale" manifest might look.
120 |
121 | ```json
122 | {
123 | "spec": {
124 | "repo": "someorg/somerepo",
125 | "name": "my-api",
126 | "description": "My API",
127 | "kind": "api",
128 | "lifecycleStage": "production",
129 | "version": "1.0.0",
130 | "responsible": "Someguy Someguyson",
131 | "team": "ThatAwesomeTeam",
132 | "system": "some-system",
133 | "domain": "some-domain",
134 | "dataSensitivity": "public",
135 | "tags": ["typescript", "backend"]
136 | },
137 | "relations": ["my-other-service"],
138 | "support": {
139 | "resolverGroup": "ThatAwesomeTeam"
140 | },
141 | "slo": [
142 | {
143 | "description": "Max latency must be 350ms for the 90th percentile",
144 | "type": "latency",
145 | "implementation": "(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())",
146 | "target": "350ms",
147 | "period": 30
148 | }
149 | ],
150 | "api": [
151 | {
152 | "name": "My API",
153 | "schemaPath": "./api/schema.yml"
154 | }
155 | ],
156 | "metadata": {},
157 | "links": [
158 | {
159 | "url": "https://my-confluence.atlassian.net/wiki/spaces/DEV/pages/123456789/",
160 | "title": "Confluence documentation",
161 | "icon": "documentation"
162 | }
163 | ]
164 | }
165 | ```
166 |
167 | ### Specification
168 |
169 | Please find a more exact description in `src/interfaces/Manifest.ts`.
170 |
171 | ## Validation and sanitization
172 |
173 | Input data is processed when Catalogist attempts to form input data into a Manifest internally. During that step we coerce the input into a new object (stringify, then parse as a new object), drop unknown keys, check the size of the remaining object, and also check for missing information. See `src/domain/valueObjects/Manifest.ts`.
174 |
175 | Because there is a bit of customization allowed, Catalogist will only drop unknown keys from the root object and from within the `spec` object.
176 |
177 | ### Rules and limits
178 |
179 | - All POST request input is sanitized.
180 | - Regex matching will be used to discard a wide degree of less common characters. Less aggressive replacement is enforced on `spec.description` and `slo[].implementation` fields.
181 | - Custom key names (in the `support` and/or `metadata` fields) may be 50 characters long.
182 | - Custom values (in the `support` and/or `metadata` fields) may be 500 characters long.
183 | - The maximum ingoing payload size must be less than 20000 characters when stringified.
184 | - You are allowed to use a maximum of 20 items in the `api`, `slo` and `links` arrays.
185 | - You are allowed to use a maximum of 50 items in the `relations` array.
186 |
187 | ---
188 |
189 | ## Example API calls
190 |
191 | - [Create a record](#create-a-record)
192 | - [Get exact record](#get-exact-record)
193 | - [Get records in given Git repository](#get-records-in-given-git-repository)
194 |
195 | **Note that `GET` requests will always return an array, even if the result set is empty.**
196 |
197 | ### Create a record
198 |
199 | This is the most minimal, valid example you can create a record with.
200 |
201 | #### Example request
202 |
203 | **POST** `{{BASE_URL}}/record`
204 |
205 | ```json
206 | {
207 | "spec": {
208 | "repo": "someorg/somerepo",
209 | "name": "my-api",
210 | "description": "My API"
211 | }
212 | }
213 | ```
214 |
215 | #### Example response
216 |
217 | `204 No Content`
218 |
219 | ### Get exact record
220 |
221 | To get an exact record yoy will need to call the API with the `repo` and `service` parameters.
222 |
223 | #### Example request
224 |
225 | **GET** `{{BASE_URL}}/record?repo=someorg/somerepo&service=my-api`
226 |
227 | #### Example response
228 |
229 | ```json
230 | [
231 | {
232 | "spec": {
233 | "repo": "someorg/somerepo",
234 | "name": "my-api",
235 | "description": "My API",
236 | "kind": "api",
237 | "lifecycleStage": "somelifecycle",
238 | "version": "1.0.0",
239 | "responsible": "Someguy Someguyson",
240 | "team": "ThatAwesomeTeam",
241 | "system": "some-system",
242 | "domain": "some-domain",
243 | "dataSensitivity": "public",
244 | "tags": ["typescript", "backend"]
245 | },
246 | "relations": ["my-other-service"],
247 | "support": {
248 | "resolverGroup": "ThatAwesomeTeam"
249 | },
250 | "api": [
251 | {
252 | "name": "My API",
253 | "schemaPath": "./api/schema.yml"
254 | }
255 | ],
256 | "slo": [
257 | {
258 | "description": "Max latency must be 350ms for the 90th percentile",
259 | "type": "latency",
260 | "implementation": "(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())",
261 | "target": "350ms",
262 | "period": 30
263 | }
264 | ],
265 | "links": [
266 | {
267 | "url": "https://my-confluence.atlassian.net/wiki/spaces/DEV/pages/123456789/",
268 | "title": "Confluence documentation",
269 | "icon": "documentation"
270 | }
271 | ],
272 | "timestamp": 1679155957000
273 | }
274 | ]
275 | ```
276 |
277 | ### Get records in given Git repository
278 |
279 | Note that it's possible to get multiple responses in a GET call if you are only providing the `repo` parameter, as you might have several services referring to the same Git repository.
280 |
281 | #### Example request
282 |
283 | **GET** `{{BASE_URL}}/record?repo=someorg/somerepo`
284 |
285 | #### Example response
286 |
287 | ```json
288 | [
289 | {
290 | "spec": {
291 | "repo": "someorg/somerepo",
292 | "name": "my-api",
293 | "description": "My API",
294 | "kind": "api",
295 | "lifecycleStage": "somelifecycle",
296 | "version": "1.0.0",
297 | "responsible": "Someguy Someguyson",
298 | "team": "ThatAwesomeTeam",
299 | "system": "some-system",
300 | "domain": "some-domain",
301 | "dataSensitivity": "public",
302 | "tags": ["typescript", "backend"]
303 | },
304 | "relations": ["my-other-service"],
305 | "support": {
306 | "resolverGroup": "ThatAwesomeTeam"
307 | },
308 | "api": [
309 | {
310 | "name": "My API",
311 | "schemaPath": "./api/schema.yml"
312 | }
313 | ],
314 | "slo": [
315 | {
316 | "description": "Max latency must be 350ms for the 90th percentile",
317 | "type": "latency",
318 | "implementation": "(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())",
319 | "target": "350ms",
320 | "period": 30
321 | }
322 | ],
323 | "links": [
324 | {
325 | "url": "https://my-confluence.atlassian.net/wiki/spaces/DEV/pages/123456789/",
326 | "title": "Confluence documentation",
327 | "icon": "documentation"
328 | }
329 | ],
330 | "timestamp": 1679155957000
331 | },
332 | {
333 | "spec": {
334 | "repo": "someorg/somerepo",
335 | "name": "my-other-api",
336 | "description": "My Other API"
337 | },
338 | "timestamp": 1679155958000
339 | }
340 | ]
341 | ```
342 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | These versions of `catalogist` are currently being supported with security updates.
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | 1.x.x | :white_check_mark: |
10 | | 0.x.x | :x: |
11 |
12 | ## Reporting a Vulnerability
13 |
14 | Only send vulnerability reports via a private conversation. Please prepare proof of the security vulnerability, and if possible, a mitigation strategy if one is identified.
15 |
--------------------------------------------------------------------------------
/api/schema.yml:
--------------------------------------------------------------------------------
1 | openapi: 3.0.0
2 | info:
3 | version: 3.0.0
4 | title: Catalogist
5 | description: Catalogist is the easy way to catalog and make your software and (micro)services visible to your organization in a lightweight and developer-friendly way.
6 |
7 | servers:
8 | - url: https://RANDOM.execute-api.REGION.amazonaws.com
9 | description: API server
10 |
11 | paths:
12 | /record:
13 | get:
14 | description: Get record
15 | operationId: getRecord
16 | responses:
17 | default:
18 | $ref: '#/components/responses/401'
19 | '200':
20 | description: Successful response
21 | content:
22 | application/json:
23 | schema:
24 | $ref: '#/components/schemas/Manifest'
25 | '401':
26 | $ref: '#/components/responses/401'
27 | '403':
28 | $ref: '#/components/responses/403'
29 | '404':
30 | $ref: '#/components/responses/404'
31 | '406':
32 | $ref: '#/components/responses/406'
33 | '429':
34 | $ref: '#/components/responses/429'
35 | parameters:
36 | - in: query
37 | name: repo
38 | description: What is the name of the repository (e.g. "someorg/somerepo")?
39 | required: true
40 | schema:
41 | type: string
42 | - in: query
43 | name: service
44 | description: What is the name of the service?
45 | required: false
46 | schema:
47 | type: string
48 | security:
49 | - apiKey: []
50 | post:
51 | description: Create record
52 | operationId: createRecord
53 | responses:
54 | default:
55 | $ref: '#/components/responses/403'
56 | '204':
57 | description: Successful response
58 | '401':
59 | $ref: '#/components/responses/401'
60 | '403':
61 | $ref: '#/components/responses/403'
62 | '404':
63 | $ref: '#/components/responses/404'
64 | '406':
65 | $ref: '#/components/responses/406'
66 | '429':
67 | $ref: '#/components/responses/429'
68 | requestBody:
69 | $ref: '#/components/requestBodies/CreateRecord'
70 | security:
71 | - apiKey: []
72 |
73 | components:
74 | securitySchemes:
75 | apiKey:
76 | type: apiKey
77 | name: Authorization
78 | in: header
79 | schemas:
80 | Manifest:
81 | type: object
82 | additionalProperties: false
83 | properties:
84 | spec:
85 | $ref: '#/components/schemas/Spec'
86 | relations:
87 | $ref: '#/components/schemas/Relations'
88 | support:
89 | $ref: '#/components/schemas/Support'
90 | slo:
91 | $ref: '#/components/schemas/Slo'
92 | api:
93 | $ref: '#/components/schemas/Api'
94 | metadata:
95 | $ref: '#/components/schemas/Metadata'
96 | links:
97 | $ref: '#/components/schemas/Links'
98 | Spec:
99 | type: object
100 | required:
101 | - repo
102 | - name
103 | additionalProperties: false
104 | properties:
105 | repo:
106 | type: string
107 | description: Name of the repository where the code base is stored.
108 | minLength: 1
109 | maxLength: 500
110 | example: someorg/somerepo
111 | name:
112 | type: string
113 | description: Name of the component.
114 | minLength: 1
115 | maxLength: 500
116 | example: my-service
117 | description:
118 | type: string
119 | description: Describes the component.
120 | minLength: 1
121 | maxLength: 1500
122 | example: My Service handles all the transactions for my book club
123 | kind:
124 | type: string
125 | description: Describes which type of solution this is.
126 | enum:
127 | - service
128 | - api
129 | - component
130 | - cots
131 | - product
132 | - external
133 | - other
134 | lifecycleStage:
135 | type: string
136 | description: The lifecycle stage this component is in.
137 | minLength: 1
138 | maxLength: 500
139 | example: prod
140 | version:
141 | type: string
142 | description: The version of the component.
143 | minLength: 1
144 | maxLength: 500
145 | example: 1.0.0
146 | responsible:
147 | type: string
148 | description: An individual that is responsible for this component.
149 | minLength: 1
150 | maxLength: 500
151 | example: Someguy Someguyson
152 | team:
153 | type: string
154 | description: The team responsible for this component.
155 | minLength: 1
156 | maxLength: 500
157 | example: ThatAwesomeTeam
158 | system:
159 | type: string
160 | description: The system this component is part of.
161 | minLength: 1
162 | maxLength: 500
163 | example: Transactions
164 | domain:
165 | type: string
166 | description: The domain this component is part of.
167 | minLength: 1
168 | maxLength: 500
169 | example: BookClub
170 | dataSensitivity:
171 | type: string
172 | description: The level of data sensitivity.
173 | enum:
174 | - public
175 | - internal
176 | - secret
177 | - other
178 | tags:
179 | type: array
180 | description: An optional list of tags.
181 | maxItems: 20
182 | items:
183 | type: string
184 | minLength: 1
185 | maxLength: 500
186 | Relations:
187 | type: array
188 | maxItems: 50
189 | items:
190 | type: string
191 | minLength: 1
192 | maxLength: 500
193 | Support:
194 | type: object
195 | additionalProperties:
196 | type: string
197 | minLength: 1
198 | maxLength: 500
199 | Slo:
200 | type: array
201 | maxItems: 20
202 | items:
203 | $ref: '#/components/schemas/SloItem'
204 | SloItem:
205 | type: object
206 | description: Service level objective (SLO) information.
207 | required:
208 | - description
209 | - type
210 | - target
211 | - period
212 | properties:
213 | description:
214 | type: string
215 | description: Describes what the SLO does and measures.
216 | minLength: 1
217 | maxLength: 500
218 | type:
219 | type: string
220 | description: What type of SLO is this?
221 | enum:
222 | - latency
223 | - availability
224 | - correctness
225 | - other
226 | implementation:
227 | type: string
228 | description: Optional implementation query.
229 | minLength: 1
230 | maxLength: 1500
231 | target:
232 | type: string
233 | description: Compliance target, typically described as a percentage, percentile, or duration.
234 | minLength: 1
235 | maxLength: 500
236 | example: "99.9%"
237 | period:
238 | type: number
239 | description: Compliance period in days.
240 | Api:
241 | type: array
242 | maxItems: 20
243 | items:
244 | $ref: '#/components/schemas/ApiItem'
245 | ApiItem:
246 | type: object
247 | required:
248 | - name
249 | additionalProperties:
250 | type: string
251 | minLength: 1
252 | maxLength: 500
253 | properties:
254 | name:
255 | type: string
256 | description: Name of the API.
257 | minLength: 1
258 | maxLength: 500
259 | schemaPath:
260 | type: string
261 | description: Path to a schema or definition of the API.
262 | minLength: 1
263 | maxLength: 500
264 | Metadata:
265 | type: object
266 | additionalProperties:
267 | type: string
268 | minLength: 1
269 | maxLength: 500
270 | Links:
271 | type: array
272 | maxItems: 20
273 | items:
274 | $ref: '#/components/schemas/LinkItem'
275 | LinkItem:
276 | type: object
277 | additionalProperties: false
278 | required:
279 | - url
280 | - title
281 | - icon
282 | properties:
283 | url:
284 | type: string
285 | description: URL for the link.
286 | minLength: 1
287 | maxLength: 500
288 | title:
289 | type: string
290 | description: Title and description of the link.
291 | minLength: 1
292 | maxLength: 500
293 | icon:
294 | type: string
295 | description: What type of icon should represent this?
296 | enum:
297 | - web
298 | - api
299 | - service
300 | - documentation
301 | - task
302 | - dashboard
303 | - other
304 |
305 | requestBodies:
306 | CreateRecord:
307 | description: Create record request body
308 | required: true
309 | content:
310 | application/json:
311 | schema:
312 | $ref: "#/components/schemas/Manifest"
313 |
314 | responses:
315 | "401":
316 | description: Unauthorized
317 | content:
318 | text/plain:
319 | schema:
320 | title: Unauthorized
321 | type: string
322 | example: Unauthorized
323 | "403":
324 | description: Forbidden
325 | content:
326 | text/plain:
327 | schema:
328 | title: Forbidden
329 | type: string
330 | example: Forbidden
331 | "404":
332 | description: Not found
333 | content:
334 | text/plain:
335 | schema:
336 | title: Not found
337 | type: string
338 | example: Not found
339 | "406":
340 | description: Not acceptable
341 | content:
342 | text/plain:
343 | schema:
344 | title: Not acceptable
345 | type: string
346 | example: Not acceptable
347 | "429":
348 | description: Too many requests
349 | content:
350 | text/plain:
351 | schema:
352 | title: Too many requests
353 | type: string
354 | example: Too many requests
--------------------------------------------------------------------------------
/arkit.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://arkit.js.org/schema.json",
3 | "excludePatterns": ["tests/", "testdata/"],
4 | "components": [
5 | {
6 | "type": "Adapters",
7 | "patterns": ["src/infrastructure/adapters/web/*.ts"]
8 | },
9 | {
10 | "type": "Domain entities",
11 | "patterns": ["src/domain/entities/*.ts"]
12 | },
13 | {
14 | "type": "Domain value objects",
15 | "patterns": ["src/domain/valueObjects/*.ts"]
16 | },
17 | {
18 | "type": "Repositories",
19 | "patterns": ["src/infrastructure/repositories/*.ts"]
20 | },
21 | {
22 | "type": "Usecases",
23 | "patterns": ["src/usecases/*.ts"]
24 | }
25 | ],
26 | "output": [
27 | {
28 | "path": "images/arkit.svg",
29 | "groups": [
30 | {
31 | "first": true,
32 | "type": "Adapters",
33 | "components": ["Adapters"]
34 | },
35 | {
36 | "type": "Domain entities",
37 | "components": ["Domain entities"]
38 | },
39 | {
40 | "type": "Domain value objects",
41 | "components": ["Domain value objects"]
42 | },
43 | {
44 | "type": "Repositories",
45 | "components": ["Repositories"]
46 | },
47 | {
48 | "type": "Usecases",
49 | "components": ["Usecases"]
50 | }
51 | ]
52 | }
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/diagrams/catalogist-diagram.drawio:
--------------------------------------------------------------------------------
1 | 7Vxrb+o2GP41SNuHg5KYhPCRS+mO1knVqmndp8oQQ7yGmOOYA/TXz06ckMQG0i6QXkJVEb++v9cn9tt2wHi1u6Vw7f9BPBR0LMPbdcCkY1mmPbD5l6DsE8oA9BLCkmJPNjoQHvALkkRDUjfYQ1GhISMkYHhdJM5JGKI5K9AgpWRbbLYgQXHWNVwihfAwh4FK/Rt7zE+ortU/0H9DeOmnM5vOIKlZwbSx3EnkQ49scyRw0wFjSghLnla7MQoE81K+JP2mR2qzhVEUsiodgofHp7F9M8WP/svvP9ZPk8mPu29ylJ8w2MgNy8WyfcoBSjahh8QgRgeMtj5m6GEN56J2y2XOaT5bBbxk8scFDoIxCQiN+4KFLX4EnYQsR08+nB4xSp5RrsaJP7xGLgxRhnZHd2xmfOQKiMgKMbrnTWQHpydZL3XPSUWxPUjSdCTNz0sxbQil9iyzsQ8M5g+Sx6/gt3We38jjCiiLhDKfLEkIg5sDdVSUyKHNHSFrKYd/EWN7aU1ww0hRSsmcYqLTnOXrIhs6Ryc2JK2ZQbpE7EQ7Ry8pigLI8M/iOmrnOlC4PoZxr+H991gduDZYTsDXP5pR/rQUT8MN5yvFL3x9JIwVBHqIasV1B2fc7RVYDAO8DPnznHOU9wIjocmY+5WhrFhhz0ukiSI+ySweT8hzTXDIYg7Yo449OWUK0unJzgdXkxfkcTU8ajffjK7pOmbBdmSpsrzk4PdiM4eRTe2gaX+yWERcicrSzhb4dgXoKQrwV6SRZU5+qbhi0d6TCMdaACYzwhhZ6QQciJYjOH9exvaZ94LxR6MDTNir9IFpdLF0XtEwXGMqlCMLIqLgwcjP3ACvWYttrHZLEYm7mET9LuYaEnU3UbzAGjxqyaG6hupQXY0/dS/lTm1FrmZXmLU0XdSEc+WspPtH2T8u/CMKXTstTnb5ysk+X7pHFHPWCHklxB1mj+nA/DkZyrJl8TCUKOxzhfJA1V3+WVdu9k7qCN+p61q1uAvglPStHJeT8CR75bFPaaCee2agZNPKQENK4T7XTPrm4wu29PMcW1e5veMW2vOHZAWl3pd3mM3gklpNp0aQ41S0DLdJkGOpKMcSznBMEWQoXsacUE8FOgvKA5p4YQnxAkXskyCcsxDHsIx6QE0ahS5vlY4i4QTA3nIBb+FekVz0jNjc13J9xBk5Tn65BKyxoIi4ohJ1tL5KNNVm/MvUzVAm6mh9lWiqzUQpXXWRqKP1bXXF5d6mprdZ6h2rLNmwAIccpKXnDob6mst/pkK8Iw7JPIwKdYOePZlauboJ5gYqUWYonKf6Om0PgTGydQAxg5gFTHgWxOoBqWLZ+Rd8vkPpv00rLUuNE1PCaJ2wY4F3Yh0KLoXbqNflHiH2ud/nYj3CQSRPxVZwjZ+WUrVrga9WGQY0jV+zCHTdMKsFla/HlO81XLtVgWy/yXjtKs48idR/xmE6ap35B3Lm0757Y/Re58wnhj3mGvhVnHkAVzMP1nQM8d78uKkeMGVnEOoxU2vJrSW3lqy15OzKpjFEZijmekVEliKtSogsRV/Zycn7QF+mXRV+HTlHvA78SpeZx1+EClvXXAXFb9nPiLNwqqgH9clqtuHLO3c3WoO5OP3z5mIaV418mjOJ4sVZiV1886zIl6LXDEmISi5WkqofOOkEUbTROmRRugyxBxVvl8HFZNFXVRoyGJAlFsd6RnKMVhaINmJeOl5pwg/oevsQrsiTN1NDrHXTByNXE7KlbtQhz1Io6g8Gijz7lsa0Lnagq4JK1fl8muQM061oPhdMzlBDQhNgoMZYnKYpnY3F4IiwrnR1oQaRr56gYZ80ns+XoWGpsatN0XiLXy251ewKvbEXKvWcs83RuHiOBmgsRwO4RtfIfazigG/N2ABlBb1QxgYoZmCcbZ/aW/MZG4MPb0gRlykbiqRt4bQDGEV4npKnODjM6aWNJPrnFFlf81kGqGxvoEn8lL5RllM/boWqybyPX6JfPwk0GpyDRrVldlwN/aR61mZ25Gb4qFcIbWZH85kdjcNeoPmbg/Z6/4NadHsp2OilYPPG3F7vt5bcWvL/t2Sr6UQdYDfylvyJrvdB1T+GOHYEdaVXYs2Vwoe73teZy3Wv94F6LP9Fr/dNu+nrfdDMCd9bfNepZPH35s+qZos3fMSnSRdvczvemtsB7IvldvDi4X96JOeDh/+MAm7+Aw==
--------------------------------------------------------------------------------
/images/arkit.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/images/catalogist-diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikaelvesavuori/catalogist/739df697860ef4674b5c01c542ab21513a893d9c/images/catalogist-diagram.png
--------------------------------------------------------------------------------
/images/servicemap.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikaelvesavuori/catalogist/739df697860ef4674b5c01c542ab21513a893d9c/images/servicemap.jpeg
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "catalogist",
3 | "description": "Catalogist is the easy way to catalog and make your software and (micro)services visible to your organization in a lightweight and developer-friendly way.",
4 | "version": "3.1.0",
5 | "author": "Mikael Vesavuori",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/mikaelvesavuori/catalogist.git"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/mikaelvesavuori/catalogist/issues"
13 | },
14 | "keywords": [
15 | "service-catalog",
16 | "software-catalog",
17 | "service-landscape",
18 | "software-landscape",
19 | "software-observability",
20 | "observability",
21 | "discovery",
22 | "discovery-service",
23 | "discoverability",
24 | "software-discovery"
25 | ],
26 | "homepage": "https://github.com/mikaelvesavuori/catalogist",
27 | "scripts": {
28 | "start": "npx sls offline --reloadHandler",
29 | "deploy": "npx sls deploy",
30 | "teardown": "npx sls remove",
31 | "lint": "npx eslint './src/**/*.ts' --quiet --fix",
32 | "docs": "npx arkit && npm run docs:typedoc",
33 | "docs:typedoc": "npx typedoc --entryPoints './src' --entryPointStrategy 'expand' --exclude '**/*+(test).ts' --externalPattern '**/node_modules/**/*' --excludeExternals --out 'docs'",
34 | "build": "npx sls package",
35 | "build:hosting": "npm run docs && cp -r diagrams docs && cp -r images docs",
36 | "test": "npm run test:unit",
37 | "test:unit": "npx vitest run --coverage",
38 | "test:unit:watch": "npx vitest --watch",
39 | "test:integration": "npx ts-node tests/integration/index.ts",
40 | "licenses": "npx license-compliance --direct --allow 'MIT;ISC;0BSD;BSD-2-Clause;BSD-3-Clause;Apache-2.0;Unlicense;CC0-1.0'",
41 | "prepare": "husky install"
42 | },
43 | "dependencies": {
44 | "@aws-sdk/client-dynamodb": "3"
45 | },
46 | "devDependencies": {
47 | "@types/aws-lambda": "8",
48 | "@types/node": "22",
49 | "@typescript-eslint/eslint-plugin": "8",
50 | "@typescript-eslint/parser": "8",
51 | "@vitest/coverage-v8": "2",
52 | "arkit": "1",
53 | "eslint": "8",
54 | "eslint-config-prettier": "9",
55 | "eslint-plugin-prettier": "5",
56 | "husky": "9",
57 | "prettier": "3",
58 | "license-compliance": "latest",
59 | "serverless": "3",
60 | "serverless-esbuild": "1",
61 | "serverless-iam-roles-per-function": "3",
62 | "serverless-offline": "13",
63 | "ts-node": "latest",
64 | "tslib": "latest",
65 | "typedoc": "latest",
66 | "typescript": "5",
67 | "vitest": "2"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/serverless.yml:
--------------------------------------------------------------------------------
1 | service: catalogist
2 |
3 | provider:
4 | name: aws
5 | runtime: nodejs20.x
6 | architecture: arm64
7 | stage: ${opt:stage, 'prod'}
8 | region: ${opt:region, 'eu-north-1'}
9 | memorySize: ${opt:memory, 1024}
10 | timeout: ${opt:timeout, 10}
11 | logRetentionInDays: ${opt:logRetentionInDays, 7}
12 | versionFunctions: false
13 | httpApi:
14 | cors: false
15 | disableDefaultEndpoint: true
16 | authorizers:
17 | Authorizer:
18 | functionName: Authorizer
19 | resultTtlInSeconds: ${self:custom.config.aws.apiGatewayCachingTtl.${self:provider.stage}, '0'}
20 | identitySource:
21 | - $request.header.Authorization
22 | type: request
23 | enableSimpleResponses: true
24 | deploymentBucket:
25 | blockPublicAccess: true
26 | maxPreviousDeploymentArtifacts: 5
27 | serverSideEncryption: AES256
28 | stackTags:
29 | Usage: ${self:service}
30 | tags:
31 | Usage: ${self:service}
32 | apiGateway:
33 | minimumCompressionSize: 1024
34 |
35 | plugins:
36 | - serverless-esbuild
37 | - serverless-offline
38 | - serverless-iam-roles-per-function
39 |
40 | package:
41 | individually: true
42 |
43 | custom:
44 | config:
45 | apiKey: ${opt:apiKey, 'jdhop8dyn98aHJGa873hljajs'} # Add your desired valid API key here or use the default
46 | awsAccountNumber: ${opt:awsAccountNumber, '123412341234'} # Set this to your value if you want to use a fallback value
47 | tableName: ${self:service}-${self:provider.stage}
48 | aws:
49 | databaseArn: arn:aws:dynamodb:${aws:region}:${self:custom.config.awsAccountNumber}:table/${self:custom.config.tableName}
50 | apiGatewayCachingTtl:
51 | prod: 30
52 | dev: 0
53 | test: 0
54 | apiGatewayCachingTtlValue: ${self:custom.aws.apiGatewayCachingTtl.${self:provider.stage}, self:custom.aws.apiGatewayCachingTtl.test} # See: https://forum.serverless.com/t/api-gateway-custom-authorizer-caching-problems/4695
55 | esbuild:
56 | bundle: true
57 | minify: true
58 |
59 | functions:
60 | Authorizer:
61 | handler: src/infrastructure/authorizers/Authorizer.handler
62 | description: ${self:service} authorizer
63 | environment:
64 | API_KEY: ${self:custom.config.apiKey}
65 | CreateRecord:
66 | handler: src/infrastructure/adapters/web/CreateRecord.handler
67 | description: Create record
68 | events:
69 | - httpApi:
70 | method: POST
71 | path: /record
72 | authorizer:
73 | name: Authorizer
74 | iamRoleStatements:
75 | - Effect: "Allow"
76 | Action:
77 | - dynamodb:PutItem
78 | Resource: ${self:custom.aws.databaseArn}
79 | environment:
80 | REGION: ${aws:region}
81 | TABLE_NAME: ${self:custom.config.tableName}
82 | GetRecord:
83 | handler: src/infrastructure/adapters/web/GetRecord.handler
84 | description: Get record
85 | events:
86 | - httpApi:
87 | method: GET
88 | path: /record
89 | authorizer:
90 | name: Authorizer
91 | iamRoleStatements:
92 | - Effect: "Allow"
93 | Action:
94 | - dynamodb:Query
95 | Resource: ${self:custom.aws.databaseArn}
96 | environment:
97 | REGION: ${aws:region}
98 | TABLE_NAME: ${self:custom.config.tableName}
99 |
100 | resources:
101 | Resources:
102 | # DynamoDB configuration
103 | CatalogistTable:
104 | Type: AWS::DynamoDB::Table
105 | DeletionPolicy: Retain
106 | UpdateReplacePolicy: Retain
107 | Properties:
108 | TableName: ${self:custom.config.tableName}
109 | AttributeDefinitions:
110 | - AttributeName: pk
111 | AttributeType: S
112 | - AttributeName: sk
113 | AttributeType: S
114 | KeySchema:
115 | - AttributeName: pk
116 | KeyType: HASH
117 | - AttributeName: sk
118 | KeyType: RANGE
119 | TimeToLiveSpecification:
120 | AttributeName: expiresAt
121 | Enabled: true
122 | BillingMode: PAY_PER_REQUEST
123 | PointInTimeRecoverySpecification:
124 | PointInTimeRecoveryEnabled: true
--------------------------------------------------------------------------------
/src/application/errors/GetDataError.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Used when error occurs while getting data.
3 | */
4 | export class GetDataError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = 'GetDataError';
8 | this.message = message;
9 | console.error(message);
10 |
11 | // @ts-ignore
12 | this.cause = {
13 | statusCode: 500
14 | };
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/application/errors/MissingSpecKeysError.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Used when a required key in the `spec` block is missing.
3 | */
4 | export class MissingSpecKeysError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = 'MissingSpecKeysError';
8 | this.message = message;
9 | console.error(message);
10 |
11 | // @ts-ignore
12 | this.cause = {
13 | statusCode: 400
14 | };
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/application/errors/SizeError.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Used when a payload is too large.
3 | */
4 | export class SizeError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = 'SizeError';
8 | this.message = message;
9 | console.error(message);
10 |
11 | // @ts-ignore
12 | this.cause = {
13 | statusCode: 400
14 | };
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/application/errors/UpdateItemError.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Used when a problem occurred while updating a database record.
3 | */
4 | export class UpdateItemError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = 'UpdateItemError';
8 | this.message = message;
9 | console.error(message);
10 |
11 | // @ts-ignore
12 | this.cause = {
13 | statusCode: 500
14 | };
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/application/errors/ValidationError.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Used when a validation problem has occurred.
3 | */
4 | export class ValidationError extends Error {
5 | constructor(message: string) {
6 | super(message);
7 | this.name = 'ValidationError';
8 | this.message = message;
9 | console.error(message);
10 |
11 | // @ts-ignore
12 | this.cause = {
13 | statusCode: 400
14 | };
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/domain/entities/Catalogist.ts:
--------------------------------------------------------------------------------
1 | import { Catalogist } from '../../interfaces/Catalogist';
2 | import { Repository } from '../../interfaces/Repository';
3 | import { Manifest } from '../../interfaces/Manifest';
4 |
5 | /**
6 | * @description Factory function for Catalogist.
7 | */
8 | export function createNewCatalogist(repo: Repository): Catalogist {
9 | return new CatalogistConcrete(repo);
10 | }
11 |
12 | /**
13 | * @description The concrete implementation for Catalogist.
14 | */
15 | class CatalogistConcrete implements Catalogist {
16 | repository: Repository;
17 |
18 | constructor(repository: Repository) {
19 | this.repository = repository;
20 | }
21 |
22 | /**
23 | * @description Create a record.
24 | */
25 | async createRecord(manifest: Manifest): Promise {
26 | await this.repository.updateItem(manifest);
27 | console.log('Created record');
28 | }
29 |
30 | /**
31 | * @description Get a record using the provided repository.
32 | */
33 | async getRecord(repo: string, service?: string): Promise[]> {
34 | const records = await this.repository.getData(repo, service);
35 |
36 | if (records && records.length === 0) return records;
37 |
38 | return records.map((record: any) => {
39 | const { spec, relations, support, slo, api, metadata, links, timestamp } = record;
40 |
41 | // Dump fields that we only needed for the persisted record
42 | const fixedRecord: Manifest = {
43 | spec,
44 | relations,
45 | support,
46 | api,
47 | slo,
48 | links,
49 | metadata,
50 | timestamp
51 | };
52 |
53 | return fixedRecord;
54 | });
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/domain/valueObjects/Manifest.ts:
--------------------------------------------------------------------------------
1 | import { LinkItem, Manifest, SloItem } from '../../interfaces/Manifest';
2 |
3 | import { SizeError } from '../../application/errors/SizeError';
4 | import { MissingSpecKeysError } from '../../application/errors/MissingSpecKeysError';
5 | import { ValidationError } from '../../application/errors/ValidationError';
6 |
7 | /**
8 | * @description Factory utility to create new Manifest value object.
9 | */
10 | export function createNewManifest(payload: any): Manifest {
11 | const manifest = new ManifestConstructor(payload);
12 | return manifest.getManifest();
13 | }
14 |
15 | /**
16 | * @description Manifest value object.
17 | */
18 | export class ManifestConstructor {
19 | manifest: Manifest;
20 | sizeThreshold = 20000;
21 | maxArrayLength = 20;
22 | maxRelationsArrayLength = 50;
23 | validKeys = {};
24 | validLaxKeys: string[] = [];
25 |
26 | constructor(payload: any) {
27 | // Setup
28 | this.validKeys = {
29 | base: ['spec', 'relations', 'support', 'api', 'slo', 'links', 'metadata'],
30 | spec: [
31 | 'repo',
32 | 'name',
33 | 'description',
34 | 'kind',
35 | 'lifecycleStage',
36 | 'version',
37 | 'responsible',
38 | 'team',
39 | 'system',
40 | 'domain',
41 | 'dataSensitivity',
42 | 'tags'
43 | ],
44 | api: ['name', 'schemaPath'],
45 | slo: ['description', 'type', 'implementation', 'target', 'period'],
46 | links: ['title', 'url', 'icon']
47 | };
48 | this.validLaxKeys = ['description', 'implementation'];
49 |
50 | // Conduct activities
51 | this.validatePayload(payload);
52 | const cleanedPayload = this.cleanPayload(payload);
53 | this.manifest = cleanedPayload;
54 | }
55 |
56 | /**
57 | * @description Do basic field validation of the incoming data object.
58 | */
59 | private validatePayload(body: Manifest | Record): void {
60 | const payload: Record = body;
61 |
62 | this.validateRequiredProperties(payload);
63 | this.validateSize(payload);
64 | this.validateArrayLengths(payload);
65 | this.validateSloPeriods(payload.slo);
66 | this.validateSloTypes(payload.slo);
67 | this.validateLinkIcon(payload.links);
68 | this.validateDataSensitivity(payload.spec.dataSensitivity);
69 | this.validateKind(payload.spec.kind);
70 | }
71 |
72 | /**
73 | * @description Validate required properties.
74 | */
75 | private validateRequiredProperties(payload: Record) {
76 | if (!payload.hasOwnProperty('spec'))
77 | throw new ValidationError('Payload is missing required field "spec"!');
78 | }
79 |
80 | /**
81 | * @description Ensure that we follow some meaningful max size cap.
82 | */
83 | private validateSize(payload: Record) {
84 | const stringifiedLength = JSON.stringify(payload).length;
85 | if (stringifiedLength >= this.sizeThreshold) throw new SizeError('Object too large!');
86 | }
87 |
88 | /**
89 | * @description Validate array lengths.
90 | */
91 | private validateArrayLengths(payload: Record) {
92 | if (payload?.relations?.length > this.maxRelationsArrayLength)
93 | throw new ValidationError('Payload has too many items in "relations" array!');
94 | if (payload?.spec?.tags?.length > this.maxArrayLength)
95 | throw new ValidationError('Payload has too many items in "tags" array!');
96 | if (payload?.api?.length > this.maxArrayLength)
97 | throw new ValidationError('Payload has too many items in "api" array!');
98 | if (payload?.slo?.length > this.maxArrayLength)
99 | throw new ValidationError('Payload has too many items in "slo" array!');
100 | if (payload?.links?.length > this.maxArrayLength)
101 | throw new ValidationError('Payload has too many items in "links" array!');
102 | }
103 |
104 | /**
105 | * @description Validate that SLOs have a valid period set.
106 | */
107 | private validateSloPeriods(slos: SloItem[]) {
108 | if (slos && slos.length > 0)
109 | slos.forEach((slo: SloItem) => {
110 | if (slo.period < 1 || slo.period > 365)
111 | throw new ValidationError(
112 | 'SLO period is out of bounds, it must be between 1 and 365 days!'
113 | );
114 | });
115 | }
116 |
117 | /**
118 | * @description Validate the SLO types.
119 | */
120 | private validateSloTypes(slos: SloItem[]) {
121 | const validValues = ['latency', 'availability', 'correctness', 'other'];
122 |
123 | if (slos && slos.length > 0)
124 | slos.forEach((slo: SloItem) => {
125 | if (validValues.includes(slo.type)) return;
126 | throw new ValidationError(
127 | 'Invalid "type" value received! It must be one of: "latency", "availability", "correctness" or "other".'
128 | );
129 | });
130 | }
131 |
132 | /**
133 | * @description Validate the link icon input.
134 | */
135 | private validateLinkIcon(links: LinkItem[]) {
136 | const validValues = ['web', 'api', 'service', 'documentation', 'task', 'dashboard', 'other'];
137 |
138 | if (links && links.length > 0)
139 | links.forEach((link: LinkItem) => {
140 | if (!link.icon || validValues.includes(link.icon)) return;
141 | throw new ValidationError(
142 | 'Invalid "icon" value received! It must be one of: "web", "api", "service", "documentation", "task", "dashboard" or "other".'
143 | );
144 | });
145 | }
146 |
147 | /**
148 | * @description Validate the data sensitivity input.
149 | */
150 | private validateDataSensitivity(input: string) {
151 | const validValues = ['public', 'internal', 'secret', 'other'];
152 | if (!input || validValues.includes(input)) return;
153 | throw new ValidationError(
154 | 'Invalid "dataSensitivity" value received! It must be one of: "public", "internal", "secret" or "other".'
155 | );
156 | }
157 |
158 | /**
159 | * @description Validate the (component) kind input.
160 | */
161 | private validateKind(input: string) {
162 | const validValues = ['service', 'api', 'component', 'cots', 'product', 'external', 'other'];
163 | if (!input || validValues.includes(input)) return;
164 | throw new ValidationError(
165 | 'Invalid "kind" value received! It must be one of: "service", "api", "component", "cots", "product", "external" or "other".'
166 | );
167 | }
168 |
169 | /**
170 | * @description Sanitize the incoming payload.
171 | */
172 | private cleanPayload(payload: any): Manifest {
173 | const validKeys: Record = this.validKeys;
174 |
175 | // Coerce the payload into a new object that does not include anything that is not serializable
176 | let cleanedPayload = JSON.parse(JSON.stringify(payload));
177 |
178 | // Check for missing required keys
179 | if (!cleanedPayload.spec['name']) throw new MissingSpecKeysError('Missing required key: name!');
180 |
181 | // Remove any unknown keys/fields from base and `spec` fields
182 | cleanedPayload = this.deleteUnknownFields(payload, validKeys['base']);
183 | cleanedPayload = this.deleteUnknownFields(payload, validKeys['spec'], 'spec');
184 |
185 | // Check and clean objects in arrays
186 | if (cleanedPayload.slo && cleanedPayload.slo.length > 0)
187 | cleanedPayload = this.cleanArrayObjects(cleanedPayload, validKeys['slo'], 'slo');
188 | if (cleanedPayload.links && cleanedPayload.links.length > 0)
189 | cleanedPayload = this.cleanArrayObjects(cleanedPayload, validKeys['links'], 'links');
190 |
191 | // Check and delete potentially useless fields
192 | cleanedPayload = this.deleteUnusedFields(cleanedPayload, [
193 | 'api',
194 | 'links',
195 | 'slo',
196 | 'support',
197 | 'metadata'
198 | ]);
199 |
200 | // Sanitize the payload (max lengths, allow only certain characters...)
201 | const sanitizedPayload = this.createSanitizedPayload(cleanedPayload);
202 |
203 | return sanitizedPayload;
204 | }
205 |
206 | /**
207 | * @description Deletes unknown fields.
208 | */
209 | private deleteUnknownFields(payload: any, validKeys: string[], fieldName?: string) {
210 | if (fieldName) {
211 | Object.keys(payload[fieldName]).forEach((key: string) => {
212 | if (!validKeys.includes(key)) delete payload[fieldName][key];
213 | });
214 | } else {
215 | Object.keys(payload).forEach((key: string) => {
216 | if (!validKeys.includes(key)) delete payload[key];
217 | });
218 | }
219 |
220 | return payload;
221 | }
222 |
223 | /**
224 | * @description Clean objects in array.
225 | */
226 | private cleanArrayObjects(payload: any, validKeys: string[], fieldName: string) {
227 | payload[fieldName].forEach((link: Record, index: number) => {
228 | Object.keys(link).forEach((key: string) => {
229 | if (!validKeys.includes(key)) delete payload[fieldName][index][key];
230 | });
231 | });
232 |
233 | return payload;
234 | }
235 |
236 | /**
237 | * @description Deletes useless fields.
238 | */
239 | private deleteUnusedFields(payload: any, fieldNames: string[]) {
240 | fieldNames.forEach((fieldName: string) => {
241 | const val = payload[fieldName] ? JSON.stringify(payload[fieldName]) : undefined;
242 | if (val && (val === '[]' || val === '{}' || val === '[{}]')) delete payload[fieldName];
243 | });
244 |
245 | return payload;
246 | }
247 |
248 | /**
249 | * @description Create a sanitized payload from a previously "cleaned" payload.
250 | */
251 | private createSanitizedPayload(payload: any) {
252 | const cleanedPayload: any = {};
253 |
254 | Object.keys(payload).forEach((keyName: string) => {
255 | // "Relations" is just a simple array so use it as-is after sanitizing
256 | if (keyName === 'relations') {
257 | cleanedPayload[keyName] = payload[keyName].map((item: string) => this.sanitizeString(item));
258 | } else if (Array.isArray(payload[keyName])) {
259 | cleanedPayload[keyName] = payload[keyName].map((innerObject: any) =>
260 | this.sanitizeObjects(innerObject)
261 | );
262 | } else {
263 | cleanedPayload[keyName] = this.sanitizeObjects(payload[keyName]);
264 | }
265 | });
266 |
267 | return cleanedPayload;
268 | }
269 |
270 | /**
271 | * @description Utility to sanitize objects in payload.
272 | */
273 | private sanitizeObjects(item: any) {
274 | const sanitizedObject: any = {};
275 | const laxKeys = this.validLaxKeys;
276 |
277 | Object.keys(item).forEach((objKey: string, index: number) => {
278 | const sanitizedKey = this.sanitizeString(objKey);
279 | const sanitizedValue = (() => {
280 | const value = Object.values(item)[index] as unknown as string | number;
281 |
282 | if (laxKeys.includes(sanitizedKey.toString()))
283 | return this.sanitizeLaxString(value as string);
284 |
285 | if (Array.isArray(value))
286 | return value.map((arrValue: string) => this.sanitizeString(arrValue, true));
287 | else return this.sanitizeString(value, true);
288 | })();
289 | sanitizedObject[sanitizedKey] = sanitizedValue;
290 | });
291 |
292 | return sanitizedObject;
293 | }
294 |
295 | /**
296 | * @description Does a special, less harsh treatment for strings.
297 | */
298 | private sanitizeLaxString(value: string) {
299 | const regexValues = new RegExp(/\\/gim); // Remove possibility of escaping
300 | return value.replace(regexValues, '').trim().substring(0, 1500);
301 | }
302 |
303 | /**
304 | * @description String sanitizer utility to cap length and allow only certain characters.
305 | * @see https://stackoverflow.com/questions/23187013/is-there-a-better-way-to-sanitize-input-with-javascript
306 | */
307 | private sanitizeString(value: string | number, isValue = false) {
308 | if (typeof value === 'number') return value;
309 | const maxLength = isValue ? 500 : 50;
310 |
311 | const regexKeys = new RegExp(/[^a-z0-9@åäöøáéíóúñü\-_]/gim);
312 | const regexValues = new RegExp(/[^a-z0-9()\[\]\/\:åäöøáéíóúñü\.\s\-_]/gim);
313 |
314 | return (isValue ? value.replace(regexValues, '') : value.replace(regexKeys, ''))
315 | .trim()
316 | .substring(0, maxLength);
317 | }
318 |
319 | /**
320 | * @description Expose the manifest data.
321 | */
322 | public getManifest(): Manifest {
323 | return this.manifest;
324 | }
325 | }
326 |
--------------------------------------------------------------------------------
/src/infrastructure/adapters/web/CreateRecord.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayProxyEventV2, APIGatewayProxyResult } from 'aws-lambda';
2 |
3 | import { createRecord } from '../../../usecases/createRecord';
4 |
5 | import { createNewDynamoRepository } from '../../repositories/DynamoDbRepo';
6 |
7 | /**
8 | * @description The controller for our service that creates new Catalogist records.
9 | */
10 | export async function handler(event: APIGatewayProxyEventV2): Promise {
11 | try {
12 | const body =
13 | event?.body && typeof event?.body === 'string' ? JSON.parse(event.body) : event.body;
14 |
15 | const repo = createNewDynamoRepository();
16 | await createRecord(repo, body);
17 |
18 | return {
19 | statusCode: 204,
20 | body: ''
21 | };
22 | } catch (error: any) {
23 | return {
24 | statusCode: error.cause?.statusCode || 500,
25 | body: JSON.stringify(error.message)
26 | };
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/infrastructure/adapters/web/GetRecord.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayProxyEventV2, APIGatewayProxyResult } from 'aws-lambda';
2 |
3 | import { getRecord } from '../../../usecases/getRecord';
4 |
5 | import { createNewDynamoRepository } from '../../repositories/DynamoDbRepo';
6 |
7 | /**
8 | * @description The controller for our service that gets Catalogist records.
9 | */
10 | export async function handler(event: APIGatewayProxyEventV2): Promise {
11 | try {
12 | const repo = createNewDynamoRepository();
13 | const data = await getRecord(repo, event);
14 |
15 | return {
16 | statusCode: 200,
17 | body: JSON.stringify(data)
18 | };
19 | } catch (error: any) {
20 | console.log('error', error);
21 | return {
22 | statusCode: error.cause?.statusCode || 500,
23 | body: JSON.stringify(error.message)
24 | };
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/infrastructure/authorizers/Authorizer.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayProxyEventV2 } from 'aws-lambda';
2 |
3 | /**
4 | * @description The authorizer controller.
5 | */
6 | export async function handler(event: APIGatewayProxyEventV2): Promise {
7 | if (event?.requestContext?.http?.method === 'OPTIONS') return handleCors();
8 |
9 | const API_KEY = process.env.API_KEY || '';
10 | if (!API_KEY) throw new Error('Missing API key in environment!');
11 |
12 | const apiKey = event.headers['Authorization'] || event.headers['authorization'] || '';
13 |
14 | return {
15 | isAuthorized: !apiKey || apiKey !== API_KEY ? false : true
16 | };
17 | }
18 |
19 | /**
20 | * @description CORS handler.
21 | */
22 | function handleCors() {
23 | return {
24 | statusCode: 200,
25 | headers: {
26 | 'Access-Control-Allow-Origin': '*'
27 | },
28 | body: JSON.stringify('OK')
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/src/infrastructure/frameworks/getQueryStringParams.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayProxyEventQueryStringParameters } from 'aws-lambda';
2 |
3 | import { QueryStringParams } from '../../interfaces/QueryStringParams';
4 |
5 | /**
6 | * @description Get cleaned query string parameters from call.
7 | */
8 | export function getQueryStringParams(
9 | queryStringParameters: APIGatewayProxyEventQueryStringParameters | Record
10 | ): QueryStringParams {
11 | return {
12 | repo: (queryStringParameters?.['repo'] as string) || '',
13 | service: (queryStringParameters?.['service'] as string) || ''
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/infrastructure/frameworks/isJsonString.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Check if JSON is really a string
3 | * @see https://stackoverflow.com/questions/3710204/how-to-check-if-a-string-is-a-valid-json-string-in-javascript-without-using-try
4 | */
5 | export const isJsonString = (str: string): Record | boolean => {
6 | try {
7 | JSON.parse(str);
8 | } catch (e) {
9 | if (1 > 2) console.log(e);
10 | return false;
11 | }
12 | return true;
13 | };
14 |
--------------------------------------------------------------------------------
/src/infrastructure/repositories/DynamoDbRepo.ts:
--------------------------------------------------------------------------------
1 | import { DynamoDBClient, PutItemCommand, QueryCommand } from '@aws-sdk/client-dynamodb';
2 |
3 | import { Repository } from '../../interfaces/Repository';
4 | import { Manifest } from '../../interfaces/Manifest';
5 | import { GetDataError } from '../../application/errors/GetDataError';
6 | import { UpdateItemError } from '../../application/errors/UpdateItemError';
7 |
8 | import { isJsonString } from '../frameworks/isJsonString';
9 |
10 | const dynamoDb = new DynamoDBClient({ region: process.env.REGION || '' });
11 | const TABLE_NAME = process.env.TABLE_NAME || '';
12 |
13 | /**
14 | * @description Factory function for DynamoDB repository.
15 | */
16 | export function createNewDynamoRepository(): DynamoRepository {
17 | return new DynamoRepository();
18 | }
19 |
20 | /**
21 | * @description Concrete implementation of DynamoDB repository.
22 | */
23 | class DynamoRepository implements Repository {
24 | /**
25 | * @description Get data.
26 | */
27 | async getData(repo: string, service?: string): Promise[]> {
28 | try {
29 | const params = this.getParams(repo, service);
30 |
31 | // @ts-ignore
32 | const data = await dynamoDb.send(new QueryCommand(params));
33 |
34 | const items = data?.Items;
35 | if (!items) return [];
36 |
37 | const fixedItems: Record[] = [];
38 |
39 | if (items && typeof items === 'object' && items.length > 0) {
40 | items.forEach((item: any) => {
41 | const cleanedItem: CleanedItem = {};
42 |
43 | // This is a little trick we do to somewhat sort the order of properties
44 | const entries = Object.entries(item).reverse();
45 |
46 | entries.forEach((entry: any) => {
47 | const [_key, _val] = entry;
48 | const _query: any = Object.values(_val)[0];
49 | cleanedItem[_key] = isJsonString(_query) ? JSON.parse(_query) : _query;
50 | });
51 |
52 | fixedItems.push(cleanedItem);
53 | });
54 | }
55 |
56 | return fixedItems;
57 | } catch (error: any) {
58 | throw new GetDataError(error.message);
59 | }
60 | }
61 |
62 | /**
63 | * @description Create or update item.
64 | */
65 | async updateItem(manifest: Manifest): Promise {
66 | try {
67 | const { spec } = manifest;
68 | const { repo, name } = spec;
69 |
70 | // Set up required fields
71 | const params: any = {
72 | TableName: TABLE_NAME,
73 | Item: {
74 | pk: { S: repo },
75 | sk: { S: name },
76 | spec: { S: JSON.stringify(spec) },
77 | timestamp: { S: `${Date.now().toString()}` }
78 | }
79 | };
80 |
81 | // Add any optional fields
82 | if (manifest.relations) params.Item.relations = { S: JSON.stringify(manifest.relations) };
83 | if (manifest.support) params.Item.support = { S: JSON.stringify(manifest.support) };
84 | if (manifest.slo) params.Item.slo = { S: JSON.stringify(manifest.slo) };
85 | if (manifest.api) params.Item.api = { S: JSON.stringify(manifest.api) };
86 | if (manifest.metadata) params.Item.metadata = { S: JSON.stringify(manifest.metadata) };
87 | if (manifest.links) params.Item.links = { S: JSON.stringify(manifest.links) };
88 |
89 | await dynamoDb.send(new PutItemCommand(params));
90 | } catch (error: any) {
91 | throw new UpdateItemError(error.message);
92 | }
93 | }
94 |
95 | /**
96 | * @description Helper to get the right query parameters.
97 | */
98 | private getParams(repo: string, service?: string) {
99 | const keyConditionExpression = service ? `pk = :pk AND sk = :sk` : `pk = :pk`;
100 |
101 | const expressionAttributeValues = service
102 | ? {
103 | ':pk': { S: repo },
104 | ':sk': { S: service }
105 | }
106 | : {
107 | ':pk': { S: repo }
108 | };
109 |
110 | return {
111 | TableName: TABLE_NAME,
112 | KeyConditionExpression: keyConditionExpression,
113 | ExpressionAttributeValues: expressionAttributeValues
114 | };
115 | }
116 | }
117 |
118 | type CleanedItem = {
119 | [propertyName: string]: any;
120 | };
121 |
--------------------------------------------------------------------------------
/src/infrastructure/repositories/LocalRepo.ts:
--------------------------------------------------------------------------------
1 | import { Manifest } from '../../interfaces/Manifest';
2 | import { Repository } from '../../interfaces/Repository';
3 |
4 | import { testdata } from '../../../testdata/TestDatabase';
5 |
6 | /**
7 | * @description Factory function for local repository.
8 | */
9 | export function createNewLocalRepository(): LocalRepo {
10 | return new LocalRepo();
11 | }
12 |
13 | /**
14 | * @description The local repo acts as a simple mock for testing and similar purposes.
15 | */
16 | class LocalRepo implements Repository {
17 | async getData(repo: string, service?: string): Promise[]> {
18 | return testdata.filter((record: any) => {
19 | if (!service && record.spec.repo === repo) return record;
20 | if (record.spec.repo === repo && record.spec.name === service) return record;
21 | });
22 | }
23 |
24 | async updateItem(manifest: Manifest): Promise {
25 | console.log(JSON.stringify(manifest).substring(0, 0));
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/interfaces/Catalogist.ts:
--------------------------------------------------------------------------------
1 | import { Manifest } from './Manifest';
2 |
3 | /**
4 | * @description Catalogist interface.
5 | */
6 | export interface Catalogist {
7 | createRecord(manifest: Manifest): Promise;
8 | getRecord(repo: string, service?: string): Promise[]>;
9 | }
10 |
--------------------------------------------------------------------------------
/src/interfaces/Manifest.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description The Manifest is the container of your solution information.
3 | */
4 | export interface Manifest {
5 | spec: Spec;
6 |
7 | // Optional
8 | relations?: Relations;
9 | support?: Support;
10 | api?: Api;
11 | slo?: Slo;
12 | links?: Links;
13 | metadata?: Metadata;
14 |
15 | // Timestamp value is generated when the manifest is persisted
16 | timestamp?: string | number;
17 | }
18 |
19 | /**
20 | * @description Fundamental information about your component.
21 | */
22 | type Spec = {
23 | /**
24 | * @description Name of the repository where the code base is stored.
25 | * @example `someorg/somerepo` (GitHub format)
26 | */
27 | repo: string;
28 | /**
29 | * @description Name of the component.
30 | */
31 | name: string;
32 | /**
33 | * @description Describes the component.
34 | */
35 | description: string;
36 |
37 | // Optional
38 | kind?: Kind;
39 | /**
40 | * @description The lifecycle stage this component is in.
41 | * @example `prod`
42 | * @example `test`
43 | */
44 | lifecycleStage?: string;
45 | /**
46 | * @description The version of the component.
47 | * @example `1.0.0`
48 | */
49 | version?: string;
50 | /**
51 | * @description An individual that is responsible for this component.
52 | */
53 | responsible?: string;
54 | /**
55 | * @description The team responsible for this component.
56 | */
57 | team?: string;
58 | /**
59 | * @description The system this component is part of.
60 | */
61 | system?: string;
62 | /**
63 | * @description The domain this component is part of.
64 | */
65 | domain?: string;
66 | /**
67 | * @description The level of data sensitivity.
68 | */
69 | dataSensitivity?: DataSensitivity;
70 | /**
71 | * @description An optional list of tags.
72 | */
73 | tags?: string[];
74 | };
75 |
76 | /**
77 | * @description Describes which kind of component this is.
78 | */
79 | type Kind = 'service' | 'api' | 'component' | 'cots' | 'product' | 'external' | 'other';
80 |
81 | /**
82 | * @description The overall data sensitivity of your solution.
83 | */
84 | type DataSensitivity = 'public' | 'internal' | 'secret' | 'other';
85 |
86 | /**
87 | * @description Named relations this component has to other components.
88 | */
89 | type Relations = string[];
90 |
91 | /**
92 | * @description Support information for the component.
93 | */
94 | type Support = {
95 | [SupportData: string]: string | number;
96 | };
97 |
98 | /**
99 | * @description Array of SLO items. Max 20 items allowed.
100 | */
101 | type Slo = SloItem[];
102 |
103 | /**
104 | * @description Service level objective (SLO) information.
105 | */
106 | export type SloItem = {
107 | /**
108 | * @description Describes what the SLO does and measures.
109 | */
110 | description: string;
111 | /**
112 | * @description What type of SLO is this?
113 | */
114 | type: SloType;
115 | /**
116 | * @description Optional implementation query.
117 | * @example `(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())`
118 | */
119 | implementation?: string;
120 | /**
121 | * @description Compliance target, typically described as a percentage, percentile, or duration.
122 | * @example `99.9%`
123 | * @example `p95`
124 | * @example `350ms`
125 | */
126 | target: string;
127 | /**
128 | * @description Compliance period in days.
129 | */
130 | period: number;
131 | };
132 |
133 | /**
134 | * @description What type of SLO is this?
135 | */
136 | type SloType = 'latency' | 'availability' | 'correctness' | 'other';
137 |
138 | /**
139 | * @description Array of API items. Max 10 items allowed.
140 | */
141 | type Api = ApiItem[];
142 |
143 | /**
144 | * @description The name of any API connected to this solution.
145 | * The value should ideally point to a (local or remote) schema or definition.
146 | */
147 | type ApiItem = {
148 | /**
149 | * @description Name of the API.
150 | */
151 | name: string;
152 | /**
153 | * @description Path to a schema or definition of the API.
154 | */
155 | schemaPath?: string;
156 | };
157 |
158 | /**
159 | * @description Any optional metadata.
160 | */
161 | type Metadata = {
162 | [MetadataKey: string]: string;
163 | };
164 |
165 | /**
166 | * @description Array of Link items. Max 10 items allowed.
167 | */
168 | type Links = LinkItem[];
169 |
170 | /**
171 | * @description Link to external resources.
172 | */
173 | export type LinkItem = {
174 | /**
175 | * @description URL for the link.
176 | */
177 | url: string;
178 | /**
179 | * @description Title and description of the link.
180 | */
181 | title: string;
182 | icon?: Icon;
183 | };
184 |
185 | /**
186 | * @description The type of icon that should represent this resource.
187 | */
188 | type Icon = 'web' | 'api' | 'service' | 'documentation' | 'task' | 'dashboard' | 'other';
189 |
--------------------------------------------------------------------------------
/src/interfaces/QueryStringParams.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Cleaned query string parameters.
3 | */
4 | export type QueryStringParams = {
5 | repo: string;
6 | service: string;
7 | };
8 |
--------------------------------------------------------------------------------
/src/interfaces/Repository.ts:
--------------------------------------------------------------------------------
1 | import { Manifest } from './Manifest';
2 |
3 | /**
4 | * @description Repository interface.
5 | */
6 | export interface Repository {
7 | getData(repo: string, service?: string): Promise[]>;
8 | updateItem(manifest: Manifest): Promise;
9 | }
10 |
--------------------------------------------------------------------------------
/src/usecases/createRecord.ts:
--------------------------------------------------------------------------------
1 | import { createNewCatalogist } from '../domain/entities/Catalogist';
2 | import { createNewManifest } from '../domain/valueObjects/Manifest';
3 | import { Manifest } from '../interfaces/Manifest';
4 |
5 | import { Repository } from '../interfaces/Repository';
6 |
7 | /**
8 | * @description The use-case for creating a record.
9 | */
10 | export async function createRecord(repo: Repository, body: any): Promise {
11 | const catalogist = createNewCatalogist(repo);
12 | const manifest = createNewManifest(body);
13 | await catalogist.createRecord(manifest);
14 | return manifest;
15 | }
16 |
--------------------------------------------------------------------------------
/src/usecases/getRecord.ts:
--------------------------------------------------------------------------------
1 | import { APIGatewayProxyEventV2, APIGatewayProxyEventQueryStringParameters } from 'aws-lambda';
2 |
3 | import { createNewCatalogist } from '../domain/entities/Catalogist';
4 |
5 | import { Repository } from '../interfaces/Repository';
6 |
7 | import { getQueryStringParams } from '../infrastructure/frameworks/getQueryStringParams';
8 |
9 | /**
10 | * @description The use-case for getting records from the repository.
11 | */
12 | export async function getRecord(repo: Repository, event: APIGatewayProxyEventV2): Promise {
13 | const catalogist = createNewCatalogist(repo);
14 |
15 | const params = getQueryStringParams(
16 | event.queryStringParameters as APIGatewayProxyEventQueryStringParameters
17 | );
18 |
19 | return await catalogist.getRecord(params.repo, params.service);
20 | }
21 |
--------------------------------------------------------------------------------
/testdata/TestDatabase.ts:
--------------------------------------------------------------------------------
1 | import { Manifest } from '../src/interfaces/Manifest';
2 |
3 | export const testdata: Manifest[] = [
4 | {
5 | spec: {
6 | repo: 'someorg/somerepo',
7 | name: 'my-api',
8 | description: 'My API',
9 | kind: 'api',
10 | lifecycleStage: 'somelifecycle',
11 | version: '1.0.0',
12 | responsible: 'Someguy Someguyson',
13 | team: 'ThatAwesomeTeam',
14 | system: 'some-system',
15 | domain: 'some-domain',
16 | dataSensitivity: 'public',
17 | tags: ['typescript', 'backend']
18 | },
19 | relations: ['my-other-service'],
20 | support: {
21 | resolverGroup: 'ThatAwesomeTeam'
22 | },
23 | slo: [
24 | {
25 | description: 'Max latency must be 350ms for the 90th percentile',
26 | type: 'latency',
27 | implementation:
28 | '(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())',
29 | target: '350ms',
30 | period: 30
31 | }
32 | ],
33 | api: [
34 | {
35 | name: 'My API',
36 | schemaPath: './api/schema.yml'
37 | }
38 | ],
39 | metadata: {},
40 | links: [
41 | {
42 | url: 'https://my-confluence.atlassian.net/wiki/spaces/DEV/pages/123456789/',
43 | title: 'Confluence documentation',
44 | icon: 'documentation'
45 | }
46 | ]
47 | },
48 | {
49 | spec: {
50 | repo: 'someorg/somerepo',
51 | name: 'my-other-api',
52 | description: 'My API',
53 | kind: 'api',
54 | lifecycleStage: 'somelifecycle',
55 | version: '1.0.0',
56 | responsible: 'Someguy Someguyson',
57 | team: 'ThatAwesomeTeam',
58 | system: 'some-system',
59 | domain: 'some-domain',
60 | dataSensitivity: 'public',
61 | tags: ['typescript', 'backend']
62 | },
63 | relations: ['my-other-service'],
64 | support: {
65 | resolverGroup: 'ThatAwesomeTeam'
66 | },
67 | slo: [
68 | {
69 | description: 'Max latency must be 350ms for the 90th percentile',
70 | type: 'latency',
71 | implementation:
72 | '(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())',
73 | target: '350ms',
74 | period: 30
75 | }
76 | ],
77 | api: [
78 | {
79 | name: 'My API',
80 | schemaPath: './api/schema.yml'
81 | }
82 | ],
83 | metadata: {},
84 | links: [
85 | {
86 | url: 'https://my-confluence.atlassian.net/wiki/spaces/DEV/pages/123456789/',
87 | title: 'Confluence documentation',
88 | icon: 'documentation'
89 | }
90 | ]
91 | },
92 | {
93 | spec: {
94 | repo: 'someorg/someotherrepo',
95 | name: 'my-service',
96 | description: 'My service',
97 | kind: 'service',
98 | lifecycleStage: 'somelifecycle',
99 | version: '3.0.0',
100 | responsible: 'Someguy Someguyson',
101 | team: 'SomeOtherTeam',
102 | system: 'some-system',
103 | domain: 'some-domain',
104 | dataSensitivity: 'public',
105 | tags: ['typescript', 'backend']
106 | }
107 | }
108 | ];
109 |
--------------------------------------------------------------------------------
/testdata/requests/awsEventRequest.json:
--------------------------------------------------------------------------------
1 | {
2 | "resource": "/record",
3 | "path": "/record",
4 | "httpMethod": "GET",
5 | "headers": {
6 | "Accept": "*/*",
7 | "Authorization": "",
8 | "CloudFront-Forwarded-Proto": "https",
9 | "CloudFront-Is-Desktop-Viewer": "true",
10 | "CloudFront-Is-Mobile-Viewer": "false",
11 | "CloudFront-Is-SmartTV-Viewer": "false",
12 | "CloudFront-Is-Tablet-Viewer": "false",
13 | "CloudFront-Viewer-Country": "SE",
14 | "Host": "asdf1234x0.execute-api.eu-north-1.amazonaws.com",
15 | "User-Agent": "insomnia/2021.6.0",
16 | "Via": "2.0 a52c33748955378f279062b7fc7ef91e.cloudfront.net (CloudFront)",
17 | "X-Amz-Cf-Id": "fPS21kJz6NudC3qyNOY0MbUjtiO-foVSi_9KEl755nm80I9JW_lrGQ==",
18 | "X-Amzn-Trace-Id": "Root=1-61acf935-1129368b253940867d40eae6",
19 | "x-client-version": "2",
20 | "X-Forwarded-For": "192.168.0.1",
21 | "X-Forwarded-Port": "443",
22 | "X-Forwarded-Proto": "https"
23 | },
24 | "multiValueHeaders": {
25 | "Accept": ["*/*"],
26 | "Authorization": [""],
27 | "CloudFront-Forwarded-Proto": ["https"],
28 | "CloudFront-Is-Desktop-Viewer": ["true"],
29 | "CloudFront-Is-Mobile-Viewer": ["false"],
30 | "CloudFront-Is-SmartTV-Viewer": ["false"],
31 | "CloudFront-Is-Tablet-Viewer": ["false"],
32 | "CloudFront-Viewer-Country": ["SE"],
33 | "Host": ["asdf1234x0.execute-api.eu-north-1.amazonaws.com"],
34 | "User-Agent": ["insomnia/2021.6.0"],
35 | "Via": ["2.0 a52c33748955378f279062b7fc7ef91e.cloudfront.net (CloudFront)"],
36 | "X-Amz-Cf-Id": ["fPS21kJz6NudC3qyNOY0MbUjtiO-foVSi_9KEl755nm80I9JW_lrGQ=="],
37 | "X-Amzn-Trace-Id": ["Root=1-61acf935-1129368b253940867d40eae6"],
38 | "x-client-version": ["2"],
39 | "X-Forwarded-For": ["192.168.0.1"],
40 | "X-Forwarded-Port": ["443"],
41 | "X-Forwarded-Proto": ["https"]
42 | },
43 | "queryStringParameters": null,
44 | "multiValueQueryStringParameters": null,
45 | "pathParameters": null,
46 | "stageVariables": null,
47 | "requestContext": {
48 | "resourceId": "dhcq5l",
49 | "authorizer": {
50 | "stringKey": "",
51 | "principalId": "",
52 | "integrationLatency": 1027
53 | },
54 | "resourcePath": "/fakeUser",
55 | "httpMethod": "GET",
56 | "extendedRequestId": "J4vgbEXMAi0Fy4w=",
57 | "requestTime": "05/Dec/2021:17:39:01 +0000",
58 | "path": "/dev/fakeUser",
59 | "accountId": "123412341234",
60 | "protocol": "HTTP/1.1",
61 | "stage": "dev",
62 | "domainPrefix": "asdf1234x0",
63 | "requestTimeEpoch": 1638725941735,
64 | "requestId": "7e26d80d-6559-4a66-9d0a-10e350fcffbe",
65 | "identity": {
66 | "cognitoIdentityPoolId": null,
67 | "accountId": null,
68 | "cognitoIdentityId": null,
69 | "caller": null,
70 | "sourceIp": "192.168.0.1",
71 | "principalOrgId": null,
72 | "accessKey": null,
73 | "cognitoAuthenticationType": null,
74 | "cognitoAuthenticationProvider": null,
75 | "userArn": null,
76 | "userAgent": "insomnia/2021.6.0",
77 | "user": null
78 | },
79 | "domainName": "asdf1234x0.execute-api.eu-north-1.amazonaws.com",
80 | "apiId": "asdf1234x0"
81 | },
82 | "body": null,
83 | "isBase64Encoded": false
84 | }
85 |
--------------------------------------------------------------------------------
/testdata/requests/createRecordMissingServiceNameField.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec": {
3 | "repo": "someorg/somerepo",
4 | "description": "My API"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/testdata/requests/createRecordMissingSpecField.json:
--------------------------------------------------------------------------------
1 | {
2 | "asdf": {}
3 | }
4 |
--------------------------------------------------------------------------------
/testdata/requests/createRecordValidBasic.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec": {
3 | "repo": "someorg/somerepo",
4 | "name": "my-api",
5 | "description": "My API"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/testdata/requests/createRecordValidFull.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec": {
3 | "repo": "someorg/somerepo",
4 | "name": "my-api",
5 | "description": "My API",
6 | "kind": "api",
7 | "lifecycleStage": "production",
8 | "version": "1.0.0",
9 | "responsible": "Someguy Someguyson",
10 | "team": "ThatAwesomeTeam",
11 | "system": "some-system",
12 | "domain": "some-domain",
13 | "dataSensitivity": "public",
14 | "tags": ["typescript", "backend"]
15 | },
16 | "relations": ["my-other-service"],
17 | "support": {
18 | "resolverGroup": "ThatAwesomeTeam"
19 | },
20 | "slo": [
21 | {
22 | "description": "Max latency must be 350ms for the 90th percentile",
23 | "type": "latency",
24 | "implementation": "(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())",
25 | "target": "350ms",
26 | "period": 30
27 | }
28 | ],
29 | "api": [
30 | {
31 | "name": "My API",
32 | "schemaPath": "./api/schema.yml"
33 | }
34 | ],
35 | "metadata": {},
36 | "links": [
37 | {
38 | "url": "https://my-confluence.atlassian.net/wiki/spaces/DEV/pages/123456789/",
39 | "title": "Confluence documentation",
40 | "icon": "documentation"
41 | }
42 | ]
43 | }
44 |
--------------------------------------------------------------------------------
/testdata/requests/createRecordValidUnknownFields.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec": {
3 | "repo": "someorg/somerepo",
4 | "name": "my-service",
5 | "lifecycleStage": 123,
6 | "something": {}
7 | },
8 | "support": {},
9 | "slo": [
10 | {
11 | "description": "Max latency must be 350ms for the 90th percentile",
12 | "type": "latency",
13 | "implementation": "(sum:trace.aws.lambda.hits.by_http_status{http.status_class:2xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count() - sum:trace.aws.lambda.errors.by_http_status{http.status_class:5xx AND service IN (demoservice-user,demoservice-greet)} by {service}.as_count()) / (sum:trace.aws.lambda.hits{service IN (demoservice-user,demoservice-greet)} by {service}.as_count())",
14 | "target": "350ms",
15 | "period": 30
16 | }
17 | ],
18 | "api": [],
19 | "metadata": {},
20 | "links": [
21 | {
22 | "something": "else"
23 | }
24 | ],
25 | "something": {}
26 | }
27 |
--------------------------------------------------------------------------------
/tests/unit/getQueryStringParams.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from 'vitest';
2 |
3 | import { getQueryStringParams } from '../../src/infrastructure/frameworks/getQueryStringParams';
4 |
5 | describe('Success cases', () => {
6 | test('It should return an empty QueryStringParams object when given no input', () => {
7 | // @ts-ignore
8 | expect(getQueryStringParams()).toMatchObject({ repo: '', service: '' });
9 | });
10 |
11 | test('It should work when given a repo', () => {
12 | const data = {
13 | repo: 'someorg/somerepo'
14 | };
15 | expect(getQueryStringParams(data)).toMatchObject(data);
16 | });
17 |
18 | test('It should work when given both a repo name and service name ', () => {
19 | const data = {
20 | repo: 'someorg/somerepo',
21 | service: 'my-service'
22 | };
23 | expect(getQueryStringParams(data)).toMatchObject(data);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/tests/unit/isJsonString.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from 'vitest';
2 |
3 | import { isJsonString } from '../../src/infrastructure/frameworks/isJsonString';
4 |
5 | describe('Success cases', () => {
6 | test('It should return a "false" boolean value when given a string', () => {
7 | expect(isJsonString('asdf')).toBe(false);
8 | });
9 |
10 | test('It should return a "true" boolean value when given an stringified object', () => {
11 | expect(isJsonString('{"asdf":123}')).toBe(true);
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/tests/usecases/createRecord.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from 'vitest';
2 |
3 | import crypto from 'crypto';
4 |
5 | import { createNewLocalRepository } from '../../src/infrastructure/repositories/LocalRepo';
6 |
7 | import { createRecord } from '../../src/usecases/createRecord';
8 |
9 | import { ValidationError } from '../../src/application/errors/ValidationError';
10 | import { MissingSpecKeysError } from '../../src/application/errors/MissingSpecKeysError';
11 | import { SizeError } from '../../src/application/errors/SizeError';
12 |
13 | import createRecordMissingSpecField from '../../testdata/requests/createRecordMissingSpecField.json';
14 | import createRecordMissingServiceNameField from '../../testdata/requests/createRecordMissingServiceNameField.json';
15 | import createRecordValidFull from '../../testdata/requests/createRecordValidFull.json';
16 | import createRecordValidBasic from '../../testdata/requests/createRecordValidBasic.json';
17 | import createRecordValidUnknownFields from '../../testdata/requests/createRecordValidUnknownFields.json';
18 |
19 | const repo = createNewLocalRepository();
20 |
21 | const tooLongArray = [
22 | {},
23 | {},
24 | {},
25 | {},
26 | {},
27 | {},
28 | {},
29 | {},
30 | {},
31 | {},
32 | {},
33 | {},
34 | {},
35 | {},
36 | {},
37 | {},
38 | {},
39 | {},
40 | {},
41 | {},
42 | {}
43 | ];
44 |
45 | describe('Failure cases', () => {
46 | describe('Validation errors', () => {
47 | test('It should throw a ValidationError if missing "spec" field', async () => {
48 | expect(
49 | async () => await createRecord(repo, createRecordMissingSpecField)
50 | ).rejects.toThrowError(ValidationError);
51 | });
52 |
53 | describe('Length errors', () => {
54 | test('It should throw a ValidationError if there are more than 10 items in the "api" field', async () => {
55 | const recordTooManyApiItems: any = JSON.parse(JSON.stringify(createRecordValidBasic));
56 | recordTooManyApiItems['api'] = tooLongArray;
57 | expect(async () => await createRecord(repo, recordTooManyApiItems)).rejects.toThrowError(
58 | ValidationError
59 | );
60 | });
61 |
62 | test('It should throw a ValidationError if there are more than 10 items in the "slo" field', async () => {
63 | const recordTooManySloItems: any = JSON.parse(JSON.stringify(createRecordValidBasic));
64 | recordTooManySloItems['slo'] = tooLongArray;
65 | expect(async () => await createRecord(repo, recordTooManySloItems)).rejects.toThrowError(
66 | ValidationError
67 | );
68 | });
69 |
70 | test('It should throw a ValidationError if there are more than 10 items in the "links" field', async () => {
71 | const recordTooManyLinkItems: any = JSON.parse(JSON.stringify(createRecordValidBasic));
72 | recordTooManyLinkItems['links'] = tooLongArray;
73 | expect(async () => await createRecord(repo, recordTooManyLinkItems)).rejects.toThrowError(
74 | ValidationError
75 | );
76 | });
77 |
78 | test('It should throw a ValidationError if there are more than 10 items in the "tags" field', async () => {
79 | const recordTooManyTagItems: any = JSON.parse(JSON.stringify(createRecordValidBasic));
80 | recordTooManyTagItems.spec.tags = tooLongArray;
81 | expect(async () => await createRecord(repo, recordTooManyTagItems)).rejects.toThrowError(
82 | ValidationError
83 | );
84 | });
85 |
86 | test('It should throw a ValidationError if there are more than 50 items in the "relations" field', async () => {
87 | const recordTooManyRelationItems: any = JSON.parse(JSON.stringify(createRecordValidBasic));
88 | recordTooManyRelationItems['relations'] = [];
89 | for (let relationsCount = 0; relationsCount <= 51; relationsCount++) {
90 | recordTooManyRelationItems['relations'].push({});
91 | }
92 | expect(
93 | async () => await createRecord(repo, recordTooManyRelationItems)
94 | ).rejects.toThrowError(ValidationError);
95 | });
96 |
97 | test('It should throw a ValidationError if the number of days in an SLO is less than 1', async () => {
98 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
99 | input.slo[0].period = 0;
100 | expect(async () => await createRecord(repo, input)).rejects.toThrowError(ValidationError);
101 | });
102 |
103 | test('It should throw a ValidationError if the number of days in an SLO is more than 365', async () => {
104 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
105 | input.slo[0].period = 366;
106 | expect(async () => await createRecord(repo, input)).rejects.toThrowError(ValidationError);
107 | });
108 | });
109 |
110 | describe('Type errors', () => {
111 | test('It should throw a ValidationError if using an unknown SLO type', async () => {
112 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
113 | input.slo[0].type = 'unknown';
114 | expect(async () => await createRecord(repo, input)).rejects.toThrowError(ValidationError);
115 | });
116 |
117 | test('It should throw a ValidationError if using an unknown link icon', async () => {
118 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
119 | input.links[0].icon = 'unknown';
120 | expect(async () => await createRecord(repo, input)).rejects.toThrowError(ValidationError);
121 | });
122 |
123 | test('It should throw a ValidationError if using an unknown data sensitivity level', async () => {
124 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
125 | input.spec.dataSensitivity = 'unknown';
126 | expect(async () => await createRecord(repo, input)).rejects.toThrowError(ValidationError);
127 | });
128 |
129 | test('It should throw a ValidationError if using an unknown component kind', async () => {
130 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
131 | input.spec.kind = 'unknown';
132 | expect(async () => await createRecord(repo, input)).rejects.toThrowError(ValidationError);
133 | });
134 | });
135 | });
136 |
137 | test('It should throw a MissingSpecKeysError if missing "name" field', async () => {
138 | expect(
139 | async () => await createRecord(repo, createRecordMissingServiceNameField)
140 | ).rejects.toThrowError(MissingSpecKeysError);
141 | });
142 |
143 | test('It should throw a SizeError if payload is too large', async () => {
144 | // Ensure we absolutely use a new instance of the imported object
145 | const payloadTooLarge = JSON.parse(JSON.stringify(createRecordValidBasic));
146 |
147 | // Create a big blob of random data
148 | payloadTooLarge.metadata = crypto.randomBytes(20000).toString('hex');
149 |
150 | expect(async () => await createRecord(repo, payloadTooLarge)).rejects.toThrowError(SizeError);
151 | });
152 | });
153 |
154 | describe('Success cases', () => {
155 | test('It should create a record when given a valid, basic manifest', async () => {
156 | const result = await createRecord(repo, createRecordValidBasic);
157 | expect(result).toMatchObject(createRecordValidBasic);
158 | });
159 |
160 | test('It should create a record when given a full manifest', async () => {
161 | const result = await createRecord(repo, createRecordValidFull);
162 | expect(result).toMatchObject(createRecordValidFull);
163 | });
164 |
165 | test('It should create a sanitized record which uses an incorrect SLO implementation string', async () => {
166 | const input = createRecordValidFull;
167 | // @ts-ignore
168 | input.slo[0].implementation = 'asdf' + '\\' + 'qwerty';
169 | const result = await createRecord(repo, input);
170 | // @ts-ignore
171 | expect(result.slo[0].implementation).toBe('asdfqwerty');
172 | });
173 |
174 | test('It should create a record and clean it when given a manifest with unknown fields', async () => {
175 | const { spec } = await createRecord(repo, createRecordValidUnknownFields);
176 | expect(spec).not.toHaveProperty('something');
177 | });
178 |
179 | test('It should create a record and keep a regular free-form description intact', async () => {
180 | const input = JSON.parse(JSON.stringify(createRecordValidFull));
181 | const description =
182 | 'Markus Åhström and Wikipedia writes "asdf!#)/€" An application programming interface (@API) is a way for two or more computer programs to communicate with each other. It is a type of software interface, offering a service to other pieces of software.[1] A document or standard that describes how to build or use such a connection or interface is called an API specification. A computer system that meets this standard is said to implement or expose an API. The term API may refer either to the specification or to the implementation. In contrast to a user interface, which connects a computer to a person, an application programming interface connects computers or pieces of software to each other. It is not intended to be used directly by a person (the end user) other than a computer programmer who is incorporating it into the software. An API is often made up of different parts which act as tools or services that are available to the programmer.';
183 | input.spec.description = description;
184 | const { spec } = await createRecord(repo, input);
185 | expect(spec.description).toBe(description);
186 | });
187 | });
188 |
--------------------------------------------------------------------------------
/tests/usecases/getRecord.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from 'vitest';
2 | import { APIGatewayProxyEventV2 } from 'aws-lambda';
3 |
4 | import { getRecord } from '../../src/usecases/getRecord';
5 |
6 | import { createNewLocalRepository } from '../../src/infrastructure/repositories/LocalRepo';
7 |
8 | import awsEvent from '../../testdata/requests/awsEventRequest.json';
9 | import { testdata } from '../../testdata/TestDatabase';
10 |
11 | const repository = createNewLocalRepository();
12 |
13 | const repo = 'someorg/somerepo';
14 |
15 | describe('Success cases', () => {
16 | test('It should return an empty array if no match is found', async () => {
17 | const event = awsEvent as any;
18 | event['queryStringParameters'] = {
19 | repo: 'does-not-exist'
20 | };
21 |
22 | const response = await getRecord(repository, event as unknown as APIGatewayProxyEventV2);
23 | expect(response).toMatchObject([]);
24 | });
25 |
26 | test('It should get record(s) by repo name', async () => {
27 | const event = awsEvent as any;
28 | event['queryStringParameters'] = {
29 | repo
30 | };
31 |
32 | const expected = testdata.filter((record: any) => record.spec.repo === repo);
33 |
34 | const response = await getRecord(repository, event as unknown as APIGatewayProxyEventV2);
35 | expect(response).toMatchObject(expected);
36 | });
37 |
38 | test('It should get record by repo name and service name', async () => {
39 | const repo = 'someorg/someotherrepo';
40 | const service = 'my-service';
41 | const event = awsEvent as any;
42 | event['queryStringParameters'] = {
43 | repo,
44 | service
45 | };
46 |
47 | const record = testdata.filter(
48 | (record: any) => record.spec.repo === repo && record.spec.name === service
49 | );
50 |
51 | const response = await getRecord(repository, event as unknown as APIGatewayProxyEventV2);
52 | expect(response).toMatchObject(record);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ES2022",
4 | "allowSyntheticDefaultImports": true,
5 | "moduleResolution": "node",
6 | "outDir": "lib",
7 | "target": "ES2022",
8 | "lib": ["ES2022"],
9 | "declaration": true,
10 | "esModuleInterop": true,
11 | "importHelpers": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmitOnError": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "noUnusedLocals": true,
16 | "noUnusedParameters": true,
17 | "removeComments": true,
18 | "resolveJsonModule": true,
19 | "sourceMap": true,
20 | "strict": true,
21 | "strictFunctionTypes": true,
22 | "skipLibCheck": true
23 | },
24 | "include": [
25 | "./src/**/*.ts",
26 | "./tests/**/*.ts",
27 | "__finished__/tests/unit/greet.test.ts",
28 | "__finished__/tests/unit/swapi.test.ts"
29 | ],
30 | "exclude": ["./node_modules/", "./lib/"]
31 | }
32 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | coverage: {
6 | enabled: true,
7 | reportsDirectory: './coverage',
8 | reporter: ['text', 'lcov'],
9 | include: ['src/**/*.ts', '!src/index.ts'],
10 | exclude: ['src/*.ts', '**/interfaces/*', '**/node_modules/**']
11 | },
12 | include: ['tests/**/*.ts']
13 | }
14 | });
15 |
--------------------------------------------------------------------------------