├── .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 | ![Build Status](https://github.com/mikaelvesavuori/catalogist/workflows/catalogist/badge.svg) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fmikaelvesavuori%2Fcatalogist.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fmikaelvesavuori%2Fcatalogist?ref=badge_shield) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=mikaelvesavuori_catalogist&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mikaelvesavuori_catalogist) [![CodeScene Code Health](https://codescene.io/projects/22501/status-badges/code-health)](https://codescene.io/projects/22501) [![CodeScene System Mastery](https://codescene.io/projects/22501/status-badges/system-mastery)](https://codescene.io/projects/22501) [![codecov](https://codecov.io/gh/mikaelvesavuori/catalogist/branch/main/graph/badge.svg?token=AIV06YBT8U)](https://codecov.io/gh/mikaelvesavuori/catalogist) [![Maintainability](https://api.codeclimate.com/v1/badges/1a609622737c6c48225c/maintainability)](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 | !["Big ball of mud"](images/servicemap.jpeg) 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 | ![Catalogist diagram](images/catalogist-diagram.png) 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 | AdaptersDomain entitiesDomain value objectsRepositoriesUsecasesCreateRecordGetRecordCatalogistManifestDynamoDbRepocreateRecordgetRecord -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------