diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index f9e47ce..0000000 --- a/.eslintrc +++ /dev/null @@ -1,79 +0,0 @@ -extends: 'eslint:recommended' - -env: - node: true - es6: true - -parserOptions: - ecmaVersion: 2017 - -rules: - # Possible Errors - no-console: 0 - valid-jsdoc: [0, {requireReturn: false, requireParamDescription: false, requireReturnDescription: false}] - - # Best Practices - consistent-return: 0 - curly: 0 - block-scoped-var: 2 - no-else-return: 2 - no-process-env: 2 - no-self-compare: 2 - no-throw-literal: 2 - no-void: 2 - radix: 2 - wrap-iife: [2, outside] - - # Variables - no-shadow: 0 - no-use-before-define: [2, nofunc] - no-unused-vars: [2, { "argsIgnorePattern": "next" }] - - # Node.js - no-process-exit: 0 - handle-callback-err: [2, err] - no-new-require: 2 - no-path-concat: 2 - - # Stylistic Issues - quotes: [2, single] - camelcase: 0 - indent: [2, 2] - no-lonely-if: 2 - no-floating-decimal: 2 - brace-style: [2, 1tbs, { "allowSingleLine": true }] - comma-style: [2, last] - consistent-this: [0, self] - func-style: 0 - max-nested-callbacks: 0 - new-cap: [2, {capIsNewExceptions: [JID]}] - no-multiple-empty-lines: [2, {max: 1}] - no-nested-ternary: 2 - semi-spacing: [2, {before: false, after: true}] - operator-assignment: [2, always] - padded-blocks: [2, never] - quote-props: [2, as-needed] - space-before-function-paren: [2, always] - keyword-spacing: [2, {after: true}] - space-before-blocks: [2, always] - array-bracket-spacing: [2, never] - computed-property-spacing: [2, never] - space-in-parens: [2, never] - space-unary-ops: [2, {words: true, nonwords: false}] - #spaced-line-comment: [2, always] - wrap-regex: 2 - linebreak-style: [2, unix] - semi: [2, always] - - # ECMAScript 6 - arrow-spacing: [2, {before: true, after: true}] - no-class-assign: 2 - no-const-assign: 2 - no-dupe-class-members: 2 - no-this-before-super: 2 - no-var: 2 - object-shorthand: [2, always] - prefer-arrow-callback: 2 - prefer-const: 2 - prefer-spread: 2 - prefer-template: 2 diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..6c95aab --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,14 @@ +env: + browser: true + es2021: true +extends: + - eslint:recommended + - plugin:@typescript-eslint/recommended +overrides: [] +parser: '@typescript-eslint/parser' +parserOptions: + ecmaVersion: latest + sourceType: module +plugins: + - '@typescript-eslint' +rules: {} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..521bfaa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,230 @@ +name: Build + +on: + push: + branches: [ "main", release/*, story/*, task/* ] + paths: + - 'src/**' + - 'events/**' + - 'tests/**' + - 'package.json' + - '.github/workflows/**' + pull_request: + # branches: [ "main" ] + types: [opened, synchronize, reopened] + paths: + - 'src/**' + - 'events/**' + - 'tests/**' + - 'package.json' + workflow_dispatch: + +env: + API_DOCKER_REGISTRY: ghcr.io + API_DOCKER_IMAGE_NAME: ${{ github.repository_owner }}/docs-func-aws-template-list-v1 + +permissions: + pull-requests: read # allows SonarCloud to decorate PRs with analysis results + +jobs: + build_job: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Git checkout + uses: actions/checkout@v3 + + - name: Install node v18 + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v3 + env: + cache-name: cache-node-modules + with: + path: | + '**/node_modules' + key: ${{ runner.os }}-yarn-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ env.cache-name }}- + ${{ runner.os }}-yarn- + ${{ runner.os }}- + + - name: Yarn install + run: yarn install + + - name: Yarn build + run: yarn build + + test_job: + name: Run tests + needs: build_job + runs-on: ubuntu-latest + environment: ci + + steps: + - name: Git checkout + uses: actions/checkout@v3 + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v3 + env: + cache-name: cache-node-modules + with: + path: | + '**/node_modules' + key: ${{ runner.os }}-yarn-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ env.cache-name }}- + ${{ runner.os }}-yarn- + ${{ runner.os }}- + + - name: Yarn install + run: yarn install + + - name: Build project + run: yarn build + + - name: Run tests + env: + REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_BUCKETNAME: ${{ secrets.TEST_AWS_BUCKETNAME }} + REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_REGION: ${{ secrets.TEST_AWS_REGION }} + REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_PREFIX: ${{ secrets.TEST_AWS_BUCKET_PREFIX }} + + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: yarn test + + analyze_job: + name: Code analysis + needs: + - build_job + - test_job + runs-on: ubuntu-latest + environment: ci + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v3 + env: + cache-name: cache-node-modules + with: + path: | + '**/node_modules' + key: ${{ runner.os }}-yarn-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ env.cache-name }}- + ${{ runner.os }}-yarn- + ${{ runner.os }}- + + - name: Yarn install + run: yarn install + + - name: Build project + run: yarn build + + - name: Run tests + env: + REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_BUCKETNAME: ${{ secrets.TEST_AWS_BUCKETNAME }} + REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_REGION: ${{ secrets.TEST_AWS_REGION }} + REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_PREFIX: ${{ secrets.TEST_AWS_BUCKET_PREFIX }} + + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: yarn test -- --coverage + + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets. SONAR_TOKEN }} + + docker_build_job: + name: Containerize + needs: + - analyze_job +# if: github.ref == 'refs/heads/main' + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by gitHub/codeql-action/upload-sarif to get the Action run status + packages: write + runs-on: ubuntu-latest + environment: ci + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v3 + env: + cache-name: cache-node-modules + with: + path: | + '**/node_modules' + key: ${{ runner.os }}-yarn-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-${{ env.cache-name }}- + ${{ runner.os }}-yarn- + ${{ runner.os }}- + + - name: Yarn install + run: yarn install + + - name: Build project + run: yarn build + +# - name: Set up QEMU +# uses: docker/setup-qemu-action@v2 +# +# - name: Set up Docker Buildx +# uses: docker/setup-buildx-action@v2 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.TEST_AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Build and push Docker image +# with: +# context: . + env: + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: docs-func-template-list-v1 + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . + docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: '${{ steps.meta.outputs.tags }}' + format: 'template' + template: '@/contrib/sarif.tpl' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' + diff --git a/.gitignore b/.gitignore index 8ec346b..acba38c 100644 --- a/.gitignore +++ b/.gitignore @@ -83,12 +83,10 @@ dist # SAM directories .aws-sam aws-toolkit-ts-output +aws-toolkit-tsconfig.json built gen -.husky - - /src/.openapi-generator/ /src/api/ /src/model/ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..fe4c17a --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install commitlint --edit "" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d37daa0 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..0130859 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx --no-install validate-branch-name diff --git a/Dockerfile b/Dockerfile index 3a7f338..cba40e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,7 @@ FROM public.ecr.aws/lambda/nodejs:18 +WORKDIR ${LAMBDA_TASK_ROOT} -COPY app.ts package*.json ./ +COPY dist/* ./ -#RUN npm install -RUN npm ci --production +CMD ["index.handler"] -CMD ["app.lambdaHandler"] diff --git a/app.ts b/app.ts deleted file mode 100644 index ece95f9..0000000 --- a/app.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * - * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format - * @param {Object} event - API Gateway Lambda Proxy Input Format - * - * Context doc: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html - * @param {Object} context - * - * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html - * @returns {Object} object - API Gateway Lambda Proxy Output Format - * - */ - -import {APIGatewayEvent, APIGatewayProxyResultV2, Context} from 'aws-lambda'; -import {GetTemplatesResponse, TemplateDocument} from './src/api'; - -import {S3} from "aws-sdk"; - -const s3 = new S3({ region: process.env["repository.template.provider.aws.s3.region"]! }); - -export const lambdaHandler = async (event: APIGatewayEvent, context: Context): Promise => { - console.log(`Event: ${JSON.stringify(event, null, 2)}`); - console.log(`Context: ${JSON.stringify(context, null, 2)}`); - - const bucketName = process.env["repository.template.provider.aws.s3.bucketname"]!; - const prefix = process.env["repository.template.provider.aws.s3.prefix"]!; - - console.debug(`Listing templates from bucket: s3://${bucketName}/${prefix}...`) - - const templateList: Array = await listFilesFromS3 ({ - Bucket: bucketName, - Prefix: prefix - }, - [] - ); - - const resultObjects: GetTemplatesResponse = { - contents: templateList - }; - - return { - statusCode: 200, - body: JSON.stringify(resultObjects), - }; -}; - -export async function listFilesFromS3 (s3Request: S3.ListObjectsV2Request, - allKeys: Array): Promise> { - - console.info("Retrieving bucket contexts...", JSON.stringify(s3Request, null, 2)); - - try { - const data: S3.ListObjectsV2Output = await s3.listObjectsV2(s3Request).promise(); - let contents = data.Contents; - - console.debug("Items: ", data.KeyCount); - - if (contents) { - contents.forEach(function (content) { - if (content.Key) { - const actDoc: TemplateDocument = { - templateName: content.Key - } - allKeys.push(actDoc); - } - }); - } - - if (data.IsTruncated) { - s3Request.ContinuationToken = data.NextContinuationToken; - console.log("get further list..."); - return listFilesFromS3(s3Request, allKeys); - } else { - console.trace("Retrieved items: ", JSON.stringify(allKeys)); - - return allKeys!; - } - } catch (error) { - console.log(error); - return []; - } -} diff --git a/aws-toolkit-tsconfig.json b/aws-toolkit-tsconfig.json deleted file mode 100644 index 4c2cebd..0000000 --- a/aws-toolkit-tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions" : { - "target" : "es6", - "module" : "commonjs", - "sourceMap" : true, - "outDir" : "C:\\projects\\videki\\docs-func-aws-template-list-v2\\aws-toolkit-ts-output", - "rootDir" : ".", - "sourceRoot" : "C:\\projects\\videki\\docs-func-aws-template-list-v2", - "typeRoots" : [ "C:\\projects\\videki\\docs-func-aws-template-list-v2\\node_modules\\@types" ], - "types" : [ "node" ] - } -} \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..1bca1aa --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-typescript', + ], +}; diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..bceeba8 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,9 @@ + +module.exports = { + + extends: ['@commitlint/config-conventional'], + + rules: { + 'scope-case': [2, 'always', ['lower-case', 'kebab-case']] + } +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..e436251 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,194 @@ +/* + * For a detailed explanation regarding each configuration property, visit: + * https://jestjs.io/docs/configuration + */ + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "C:\\Users\\Levente\\AppData\\Local\\Temp\\jest", + + // Automatically clear mock calls, instances, contexts and results before every test + clearMocks: false, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: true, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // Indicates which provider should be used to instrument code for coverage + coverageProvider: 'babel', + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // The default configuration for fake timers + // fakeTimers: { + // "enableGlobally": false + // }, + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: undefined, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "mjs", + // "cjs", + // "jsx", + // "ts", + // "tsx", + // "json", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: undefined, + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state before every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state and implementation before every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // The number of seconds after which a test is considered as slow and reported as such in the results. + // slowTestThreshold: 5, + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: 'node', + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + testMatch: [ + '**/tests/unit/**/*.test.ts' + ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "\\\\node_modules\\\\" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jest-circus/runner", + + // A map from regular expressions to paths to transformers + // transform: undefined, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "\\\\node_modules\\\\", + // "\\.pnp\\.[^\\\\]+$" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/package.json b/package.json index bc572f5..f9cef7c 100644 --- a/package.json +++ b/package.json @@ -2,41 +2,53 @@ "name": "docs-func-aws-template-list-v1", "version": "0.0.1", "private": true, - "license": "UNLICENSED", + "license": "Apache-2.0", "description": "Document engine template list", "author": "Document engine contributors", "keywords": [ "yarn" ], + "main": "src/index.ts", "scripts": { - "build": "openapi-generator-cli generate -g typescript-node --global-property model -i docs/document-api-template-list-v1.yaml -o src && tsc", - "test": "npm run build && node built/app.js", + "prepare": "husky install", + "clean": "rimraf src/gen/*", + "generate-api": "openapi-generator-cli generate -g typescript-node -i docs/document-api-template-list-v1.yaml -o src/gen", + "generate-remove-unwanted": "rimraf src/gen/git_push.sh src/gen/.gitignore src/gen/.openapi-generator-ignore", + "build": "npm run clean && npm run generate-api && npm run generate-remove-unwanted && esbuild src/index.ts --bundle --minify --sourcemap --platform=node --target=es2020 --outfile=dist/index.js", + "test": "jest", "commit": "cz", - "lint:format": "eslint --fix packages/" + "lint:format": "eslint --fix src/*" }, "dependencies": { - "aws-sdk": "2.1333.0" + "@aws-sdk/client-s3": "^3.295.0" }, "devDependencies": { + "@babel/core": "^7.21.0", + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.21.0", + "@commitlint/cli": "^17.4.4", + "@commitlint/config-conventional": "^17.4.4", + "@openapitools/openapi-generator-cli": "^2.5.2", "@types/aws-lambda": "^8.10.111", + "@types/jest": "^29.4.1", "@types/node": "^18", "@types/request": "^2.48.8", - "typescript": "^4.9", - "@commitlint/cli": "^17.4.4", - "@commitlint/config-conventional": "^17.4.4", + "@typescript-eslint/eslint-plugin": "^5.56.0", + "@typescript-eslint/parser": "^5.56.0", + "babel-jest": "^29.5.0", "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", + "esbuild": "^0.17.12", "eslint": "^8.36.0", - "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^8.7.0", "eslint-plugin-import": "^2.27.5", - "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-prettier": "^4.2.1", "husky": "^8.0.3", + "jest": "^29.5.0", "lint-staged": "^13.2.0", "prettier": "^2.8.4", - "validate-branch-name": "^1.3.0", - "@openapitools/openapi-generator-cli": "^2.5.2" + "typescript": "^4.9", + "validate-branch-name": "^1.3.0" }, "config": { "commitizen": { @@ -46,15 +58,26 @@ "stylelint": { "extends": "stylelint-config-standard" }, + "husky": { + "hooks": { + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", + "pre-commit": "lint-staged", + "pre-push": "validate-branch-name" + } + }, "lint-staged": { - "packages/**/*.+(js|sx|ts|tsx)": [ - "eslint --fix ./", + "src/**/*.+(ts|tsx)": [ + "eslint --fix ", + "prettier --write" + ], + "tests/**/*.+(ts|tsx)": [ + "eslint --fix ", "prettier --write" ] }, "validate-branch-name": { - "pattern": "^main$|^release/(v\\d+.\\d+.\\d+){1}$|^prerelease/(v\\d+.\\d+.\\d+){1}$|^(feature|fix){1}/(BSSN-\\d+){1}$", - "errorMsg": "🤦‍♀️ branch name does not comply with the repository conventions" + "pattern": "^main$|^release/(v\\d+.\\d+.\\d+){1}$|^(story|task|bug){1}/(GD-\\d+-){1}[\\w_-]*$", + "errorMsg": "The branch name does not comply with the branching rules" }, "resolutions": { "ansi-regex": "^5.0.1", diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..b39c861 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,3 @@ +sonar.projectKey=get-the-docs_docs-func-aws-template-list-v1 +sonar.organization=get-the-docs +sonar.javascript.lcov.reportPaths=./coverage/lcov.info diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..488efa7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,73 @@ +/** + * + * Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format + * @param {Object} event - API Gateway Lambda Proxy Input Format + * + * Context doc: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html + * @param {Object} context + * + * Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html + * @returns {Object} object - API Gateway Lambda Proxy Output Format + * + */ + +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from "aws-lambda"; + +import { GetTemplatesResponse, TemplateDocument } from "./gen/api"; + +import { S3Provider } from "./service/s3-provider"; + +export const handler = async ( + event: APIGatewayProxyEvent, + context: Context +): Promise => { + console.log(`Event: ${JSON.stringify(event, null, 2)}`); + console.log(`Context: ${JSON.stringify(context, null, 2)}`); + + const region = process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_REGION"]!; + const bucketName = + process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_BUCKETNAME"]!; + const prefix = process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_PREFIX"]!; + + const s3Provider: S3Provider = new S3Provider(bucketName, region, prefix); + + console.debug( + `Listing templates from bucket: s3://${s3Provider.bucketName}/${s3Provider.basePath}...` + ); + + const request: any = event.body ? JSON.parse(event.body!) : undefined; + let templateId: string | undefined; + if (request) { + templateId = request.templateId; + } + + const templateList: Array | undefined = + await s3Provider.listObjects( + { + Bucket: bucketName, + Prefix: templateId ? prefix + "/" + templateId : prefix, + }, + [] + ); + + const resultObjects: GetTemplatesResponse = { + contents: templateList, + }; + + let result: APIGatewayProxyResult = { statusCode: 500, body: "" }; + if (templateList) { + result = { + statusCode: templateList!.length > 0 ? 200 : 404, + body: JSON.stringify(resultObjects), + }; + } + + console.log( + `End - Listing templates from bucket: s3://${s3Provider.bucketName}/${s3Provider.basePath}...` + ); + return result; +}; diff --git a/src/service/s3-provider.ts b/src/service/s3-provider.ts new file mode 100644 index 0000000..b753b5f --- /dev/null +++ b/src/service/s3-provider.ts @@ -0,0 +1,86 @@ + +import { + ListObjectsV2Command, + ListObjectsV2Output, + ListObjectsV2Request, + S3Client +} from "@aws-sdk/client-s3"; +import {TemplateDocument} from "../gen/model/templateDocument"; + +export class S3Provider { + protected _s3Client : any = {}; + private readonly _bucketName : string = ""; + private readonly _region : string = ""; + private readonly _basePath : string | undefined = ""; + + constructor(bucketName?: string, region?: string, basePath?: string) { + if (bucketName && region) { + this._bucketName = bucketName; + this._region = region; + } else { + throw new Error("Bucket name or region not specified."); + } + this._basePath = basePath ? basePath : ""; + + this._s3Client = new S3Client({ region: this._region }); + } + + + get s3Client() : S3Client { + return this._s3Client; + } + + get bucketName(): string { + return this._bucketName; + } + + get region(): string { + return this._region; + } + + get basePath(): string | undefined { + return this._basePath; + } + + public async listObjects (s3Request: ListObjectsV2Request, + allKeys: Array): Promise | undefined> { + + console.debug("Retrieving bucket contexts...", JSON.stringify(s3Request, null, 2)); + + try { + const listCmd : ListObjectsV2Command = new ListObjectsV2Command(s3Request); + const data: ListObjectsV2Output = await this.s3Client.send(listCmd); + + let contents = data.Contents; + + console.debug("Items: ", data.KeyCount); + + if (contents) { + contents.forEach(function (content) { + if (content.Key) { + const actDoc: TemplateDocument = { + templateName: content.Key + } + allKeys.push(actDoc); + } + }); + } + + if (data.IsTruncated) { + s3Request.ContinuationToken = data.NextContinuationToken; + + console.debug("get further list..."); + return this.listObjects(s3Request, allKeys); + } else { + console.debug("Retrieved items: ", JSON.stringify(allKeys, null, 2)); + + return allKeys!; + } + } catch (error) { + console.log(error); + + throw new Error("Error retrieving the object list from S3."); + } + } +} + diff --git a/tests/unit/handler.test.ts b/tests/unit/handler.test.ts new file mode 100644 index 0000000..1692d15 --- /dev/null +++ b/tests/unit/handler.test.ts @@ -0,0 +1,198 @@ +"use strict"; + +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from "aws-lambda"; +import { handler } from "../../src"; +import { GetTemplatesResponse } from "../../src/gen/model/getTemplatesResponse"; + +describe("Lambda handler tests - happy path", function () { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("Retrieve templates without template id should return all templates", async () => { + const event: APIGatewayProxyEvent = {} as any; + + const testContext: Partial = { + awsRequestId: "cd4b63fd-e407-450d-b24e-1e991ea43425", + logGroupName: "/aws/lambda/docs-func-aws-template-fill-v1", + logStreamName: "2023/03/01/[$LATEST]12345abcdfe01234567890abcdef1234", + functionName: "docs-func-aws-template-fill-v1", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:us-east-2:123456789012:function:docs-func-aws-template-fill-v1", + identity: undefined, + clientContext: undefined, + memoryLimitInMB: "512", + }; + + const result: APIGatewayProxyResult = await handler( + event as APIGatewayProxyEvent, + testContext as Context + ); + + expect(result.statusCode).toEqual(200); + + const resultObjects: GetTemplatesResponse = JSON.parse(result.body); + expect(resultObjects.contents!.length).toBeGreaterThan(0); + }); + + it("Retrieve given template by existing template id should return template", async () => { + const event: APIGatewayProxyEvent = { + templateId: "integrationtests/contracts/contract_v09_en.docx", + } as any; + + const testContext: Partial = { + awsRequestId: "cd4b63fd-e407-450d-b24e-1e991ea43424", + logGroupName: "/aws/lambda/docs-func-aws-template-fill-v1", + logStreamName: "2023/03/01/[$LATEST]12345abcdfe01234567890abcdef1234", + functionName: "docs-func-aws-template-fill-v1", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:us-east-2:123456789012:function:docs-func-aws-template-fill-v1", + identity: undefined, + clientContext: undefined, + memoryLimitInMB: "512", + }; + + const result: APIGatewayProxyResult = await handler( + event as APIGatewayProxyEvent, + testContext as Context + ); + + expect(result.statusCode).toEqual(200); + + const resultObjects: GetTemplatesResponse = JSON.parse(result.body); + expect(resultObjects.contents!.length).toBeGreaterThan(0); + }); + + it("Retrieve given template by non-existing template id should return 404", async () => { + const event: APIGatewayProxyEvent = { + templateId: "integrationtests/contracts/contract_v09_en.docx", + } as any; + + const testContext: Partial = { + awsRequestId: "cd4b63fd-e407-450d-b24e-1e991ea43424", + logGroupName: "/aws/lambda/docs-func-aws-template-fill-v1", + logStreamName: "2023/03/01/[$LATEST]12345abcdfe01234567890abcdef1234", + functionName: "docs-func-aws-template-fill-v1", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:us-east-2:123456789012:function:docs-func-aws-template-fill-v1", + identity: undefined, + clientContext: undefined, + memoryLimitInMB: "512", + }; + + const result: APIGatewayProxyResult = await handler( + event as APIGatewayProxyEvent, + testContext as Context + ); + + expect(result.statusCode).toEqual(200); + + const resultObjects: GetTemplatesResponse = JSON.parse(result.body); + expect(resultObjects.contents!.length).toBeGreaterThan(0); + }); +}); + +describe("Lambda handler tests - negative tests", function () { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it("Param error - Invalid bucket name", async () => { + process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_REGION"] = "us-east-2"; + process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_BUCKETNAME"] = ""; + process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_PREFIX"] = ""; + + const event: APIGatewayProxyEvent = {} as any; + + const testContext: Partial = { + awsRequestId: "cd4b63fd-e407-450d-b24e-1e991ea43425", + logGroupName: "/aws/lambda/docs-func-aws-template-fill-v1", + logStreamName: "2023/03/01/[$LATEST]12345abcdfe01234567890abcdef1234", + functionName: "docs-func-aws-template-fill-v1", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:us-east-2:123456789012:function:docs-func-aws-template-fill-v1", + identity: undefined, + clientContext: undefined, + memoryLimitInMB: "512", + }; + + await expect( + handler(event as APIGatewayProxyEvent, testContext as Context) + ).rejects.toThrow("Bucket name or region not specified."); + }); + + it("Param error - Invalid prefix", async () => { + process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_PREFIX"] = + "nonexisting_folder"; + + const event: APIGatewayProxyEvent = {} as any; + + const testContext: Partial = { + awsRequestId: "cd4b63fd-e407-450d-b24e-1e991ea43425", + logGroupName: "/aws/lambda/docs-func-aws-template-fill-v1", + logStreamName: "2023/03/01/[$LATEST]12345abcdfe01234567890abcdef1234", + functionName: "docs-func-aws-template-fill-v1", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:us-east-2:123456789012:function:docs-func-aws-template-fill-v1", + identity: undefined, + clientContext: undefined, + memoryLimitInMB: "512", + }; + + const result: APIGatewayProxyResult = await handler( + event as APIGatewayProxyEvent, + testContext as Context + ); + + expect(result.statusCode).toEqual(404); + + const resultObjects: GetTemplatesResponse = JSON.parse(result.body); + expect(resultObjects.contents!.length).toBe(0); + }); + + it("Param error - Wrong region", async () => { + process.env["REPOSITORY_TEMPLATE_PROVIDER_AWS_S3_REGION"] = "us-east-2"; + + const event: APIGatewayProxyEvent = {} as any; + + const testContext: Partial = { + awsRequestId: "cd4b63fd-e407-450d-b24e-1e991ea43425", + logGroupName: "/aws/lambda/docs-func-aws-template-fill-v1", + logStreamName: "2023/03/01/[$LATEST]12345abcdfe01234567890abcdef1234", + functionName: "docs-func-aws-template-fill-v1", + functionVersion: "$LATEST", + invokedFunctionArn: + "arn:aws:lambda:us-east-2:123456789012:function:docs-func-aws-template-fill-v1", + identity: undefined, + clientContext: undefined, + memoryLimitInMB: "512", + }; + + await expect( + handler(event as APIGatewayProxyEvent, testContext as Context) + ).rejects.toThrow("Error retrieving the object list from S3."); + }); +}); diff --git a/tests/unit/s3-provider.test.ts b/tests/unit/s3-provider.test.ts new file mode 100644 index 0000000..ca8c625 --- /dev/null +++ b/tests/unit/s3-provider.test.ts @@ -0,0 +1,22 @@ +'use strict'; + +import {S3Provider} from "../../src/service/s3-provider"; + +describe('S3 data provider connectivity', function () { + const OLD_ENV = process.env; + + beforeEach(() => { + jest.resetModules() + process.env = { ...OLD_ENV }; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('Init with no params should return throw error', async () => { + expect(() => new S3Provider(undefined, undefined, undefined)) + .toThrow("Bucket name or region not specified."); + }); + +}); diff --git a/tsconfig.json b/tsconfig.json index 6279e19..dbd079f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2017", + "target": "es2020", "strict": true, "preserveConstEnums": true, "noEmit": true, @@ -11,8 +11,17 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "isolatedModules": false, + "outDir": "dist", + "baseUrl": ".", + "paths": { + "*.ts": [ + "./node_modules/*", + "./src/*" + ] + } }, + "include": ["src/**/*.ts"], "exclude": [ - "node_modules", - "**/*.test.ts"] + "./node_modules", + "./**/*.test.ts"] }