diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 0000000..6f67112 --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,16 @@ +{ + "all": true, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/__test__/**/*.*", + "**/*.d.ts", + "**/*.types.ts", + "src/index.ts" + ], + "reporter": [ + "text", + "json" + ] +} \ No newline at end of file diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..ef7bbb2 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,29 @@ +# adding Flags to your `layout` configuration to show up in the PR comment +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false + require_base: yes + require_head: yes + branches: null +ignore: + - '**/*.spec.ts' + +flag_management: + # this section will govern all default rules of Flags + default_rules: + statuses: + - name_prefix: project- + type: project + target: auto + threshold: 1% + - name_prefix: patch- + type: patch + target: auto + threshold: 1% +# coverage: +# status: +# # pull-requests only +# patch: +# default: +# threshold: 1% diff --git a/.eslintrc.library.cjs b/.eslintrc.library.cjs new file mode 100644 index 0000000..742721e --- /dev/null +++ b/.eslintrc.library.cjs @@ -0,0 +1,61 @@ +// ESLint config for formatting the /src/**/*.ts files. +module.exports = { + root: true, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "plugin:type-only-import/recommended", + "plugin:jsdoc/recommended-typescript-error", + "plugin:prettier/recommended", + "plugin:sonarjs/recommended" + ], + plugins: [ + "type-only-import", + "jsdoc", + "prettier" + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.json", + sourceType: "module", + ecmaVersion: "latest", + tsconfigRootDir: __dirname, + }, + rules: { + "prettier/prettier": "error", + //"function-paren-newline": ["error", "always"], + "@typescript-eslint/explicit-module-boundary-types": "off", // IDE will show the return types + "@typescript-eslint/restrict-template-expressions": "off", // We are OK with whatever type within template expressions + "@typescript-eslint/unbound-method": "off", // We never use 'this' within functions anyways. + "@typescript-eslint/no-empty-function": "off", // Empty functions are ok sometimes. + "no-useless-return": "error", + "no-console": "error", + "sonarjs/no-nested-template-literals": "off", // Nested template literals are OK really + "jsdoc/require-file-overview": "error", + "jsdoc/require-jsdoc": [ + "error", + { + "publicOnly": true, + "require": { + "ArrowFunctionExpression": true, + "ClassDeclaration": true, + "ClassExpression": true, + "FunctionDeclaration": true, + "FunctionExpression": true, + "MethodDefinition": true + }, + "exemptEmptyConstructors": true, + "exemptEmptyFunctions": false, + "enableFixer": false, + "contexts": [ + "TSInterfaceDeclaration", + "TSTypeAliasDeclaration", + "TSMethodSignature", + "TSPropertySignature" + ] + } + ] + } +}; diff --git a/.eslintrc.out-ts.cjs b/.eslintrc.out-ts.cjs new file mode 100644 index 0000000..af73402 --- /dev/null +++ b/.eslintrc.out-ts.cjs @@ -0,0 +1,17 @@ +// ESLint config for formatting the resulting .d.ts files (/dist-ts/**/*.d.ts) that end up in NPM package for typing information. +const { extends: extendsArray, plugins, rules } = require("./.eslintrc.cjs"); +module.exports = { + root: true, + extends: extendsArray.filter((ext) => ext.startsWith("plugin:jsdoc/") || ext.startsWith("plugin:prettier/")), + plugins: plugins.filter((plugin) => plugin === "jsdoc" || plugin === "prettier"), + parser: "@typescript-eslint/parser", + parserOptions: { + project: "./tsconfig.out.json", + sourceType: "module", + ecmaVersion: "latest", + tsconfigRootDir: __dirname, + }, + rules: Object.fromEntries(Object.entries(rules).filter(([ruleKey]) => ruleKey.startsWith("jsdoc/") || ruleKey.startsWith("prettier/"))), + // So we won't get errors on comments disable e.g. @typescript-eslint/xyz rules. + noInlineConfig: true, +}; diff --git a/.eslintrc.out.cjs b/.eslintrc.out.cjs new file mode 100644 index 0000000..430779d --- /dev/null +++ b/.eslintrc.out.cjs @@ -0,0 +1,19 @@ +// ESLint config for formatting the resulting .[m]js files (/dist-(cjs|mjs)/**/*.[m]js) that end up in NPM package. +module.exports = { + root: true, + extends: [ + "plugin:path-import-extension/recommended", + "plugin:prettier/recommended", + ], + plugins: [ + "path-import-extension", + "prettier" + ], + parser: "@babel/eslint-parser", + parserOptions: { + requireConfigFile: false + }, + rules: { + "prettier/prettier": "error", + } +}; diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..866eaf4 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,107 @@ +name: Build, test, and publish + +on: + workflow_call: + inputs: + fetch-depth: + required: true + type: number + pre-run-function: + required: false + type: string + default: | + tyras_pre_run () + { + echo 'No pre-run.' + } + post-run-function: + required: false + type: string + default: | + tyras_post_run () + { + cd "$1" + cp ../LICENSE ./LICENSE.txt + # Note - yarn doesn't have functionality to install package without saving it to package.json (!) + # So we use global install instead. + yarn global add "@jsdevtools/npm-publish@$(cat ../versions/npm-publish)" + npm-publish --dry-run --access public + } + secrets: + npm-publish-token: + required: false + +jobs: + build_and_test: + strategy: + matrix: + dir: + - config + - typed-sql + runs-on: ubuntu-latest + name: Build and test ${{ matrix.dir }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: ${{ inputs.fetch-depth }} + + - id: prepare + name: Prepare ${{ matrix.dir }} + shell: bash + run: | + set -e + + ${{ inputs.pre-run-function }} + tyras_pre_run '${{ matrix.dir }}' + + - id: install + name: Install dependencies of ${{ matrix.dir }} + shell: bash + run: | + set -e + + ./scripts/install.sh '${{ matrix.dir }}' --frozen-lockfile + + - id: test + name: Test ${{ matrix.dir }} + shell: bash + run: | + set -e + + ./scripts/test.sh '${{ matrix.dir }}' coverage + + # Run build *after* tests - since tests no longer require transpiled JS to run + # We still want to run build to catch any TS error possibly lurking somewhere. + - id: compile + name: Compile ${{ matrix.dir }} + shell: bash + run: | + set -e + + ./scripts/build.sh '${{ matrix.dir }}' ci + + - id: lint + name: Lint ${{ matrix.dir }} + shell: bash + run: | + set -e + + ./scripts/lint.sh '${{ matrix.dir }}' + + - id: coverage + name: Upload coverage for '${{ matrix.dir }}' + uses: codecov/codecov-action@v3 + with: + flags: ${{ matrix.dir }} + directory: ${{ matrix.dir }} + + - id: finalize + name: Finalize ${{ matrix.dir }} + shell: bash + run: | + set -e + + ${{ inputs.post-run-function }} + tyras_post_run '${{ matrix.dir }}' + env: + NPM_PUBLISH_TOKEN: ${{ secrets.npm-publish-token }} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..ddf5a5a --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,50 @@ +name: CD Pipeline + +# After each successful PR merge to main branch. +on: + push: + branches: + - main + +concurrency: cd + +# Tbh, the most optimal way would be if I could reuse steps instead of jobs +# I guess that is possible if I would create separate folder for the action, but right now it seems like too heavy approach. +# Worth investigating later tho. +jobs: + cd_job: + uses: ./.github/workflows/build-and-test.yml + with: + fetch-depth: 0 # We must fetch whole history to be able to search for tags + # Don't use normal NPM publish actions in order to avoid token writing to .npmrc file. + post-run-function: | + tyras_post_run () + { + PACKAGE_VERSION="$(cat "$1/package.json" | jq -rM .version)" + GIT_TAG_NAME="$1-v${PACKAGE_VERSION}" + if [[ -n "$(git ls-remote --tags origin "${GIT_TAG_NAME}")" ]]; then + echo "Detected that tag ${GIT_TAG_NAME} already is existing, not proceeding to publish package $1" + else + # The release can be performed, start by creating Git tag locally + # If there are any errors here, we won't end up in situation where NPM package is published, but no Git tag exists for it + git config --global user.email "cd-automation@ty-ras.project" + git config --global user.name "CD Automation" + git tag \ + -a \ + -m "Component $1 release ${PACKAGE_VERSION}" \ + "${GIT_TAG_NAME}" + + # Publish NPM package + cd "$1" + cp ../LICENSE ./LICENSE.txt + # Note - yarn doesn't have functionality to install package without saving it to package.json (!) + # So we use global install instead. + yarn global add "@jsdevtools/npm-publish@$(cat ../versions/npm-publish)" + npm-publish --access public --token "${NPM_PUBLISH_TOKEN}" + + # Push Git tag + git push origin "${GIT_TAG_NAME}" + fi + } + secrets: + npm-publish-token: ${{ secrets.NPM_PUBLISH_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b3f08bb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI Pipeline + +# On each commit to PR to main branch. +on: + pull_request: + branches: + - main + +jobs: + ci_job: + uses: ./.github/workflows/build-and-test.yml + with: + fetch-depth: 1 # No need to fetch whole history for CI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..309969a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +.vscode +build +dist* +coverage +.yarn +yarn-*.log +*/.eslintrc*.cjs +*/tsconfig*.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..3f9156d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 80, + "trailingComma": "all", + "tabWidth": 2, + "useTabs": false +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..af90bfc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Typesafe REST API Specification + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4227c1 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Typesafe REST API Specification - Runtypes Extras Libraries + +[![CI Pipeline](https://github.com/ty-ras/extras-runtypes/actions/workflows/ci.yml/badge.svg)](https://github.com/ty-ras/extras-runtypes/actions/workflows/ci.yml) +[![CD Pipeline](https://github.com/ty-ras/extras-runtypes/actions/workflows/cd.yml/badge.svg)](https://github.com/ty-ras/extras-runtypes/actions/workflows/cd.yml) + +The Typesafe REST API Specification is a family of libraries used to enable seamless development of Backend and/or Frontend which communicate via HTTP protocol. +The protocol specification is checked both at compile-time and run-time to verify that communication indeed adhers to the protocol. +This all is done in such way that it does not make development tedious or boring, but instead robust and fun! + +This particular repository contains generic libraries related to using [`runtypes`](https://github.com/pelotom/runtypes): +- [typed-sql](./typed-sql) contains library which enables type-safe SQL query string specification and execution with a help of [tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates) and [`runtypes`](https://github.com/pelotom/runtypes) library for input and output validation. +- [config](./config) contains library which encapsulates common logic related to reading JSON-encoded configuration values from e.g. environment variables. diff --git a/ava.config.mjs b/ava.config.mjs new file mode 100644 index 0000000..9d25581 --- /dev/null +++ b/ava.config.mjs @@ -0,0 +1,18 @@ +export default { + cache: false, // We run Ava in non-coverage mode with 'ro' modifier for Docker volume + extensions: { + ts: "module" + }, + nodeArguments: [ + "--loader=ts-node/esm", + "--experimental-specifier-resolution=node", + "--trace-warnings" + ], + files: [ + "**/__test__/*.spec.ts" + ], + timeout: "10m", + verbose: true, + // The default is number of corse, which in CI I guess is 1 + concurrency: 5 +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..cfa218b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +. 'scripts/preamble.sh' + +TYRAS_BUILD_MODE="$1" +if [ "${TYRAS_BUILD_MODE}" = "run" ] || [ "${TYRAS_BUILD_MODE}" = "ci" ]; then + if [ "$#" -gt 0 ]; then + shift + fi +else + TYRAS_BUILD_MODE='run' +fi + +yarn run "build:${TYRAS_BUILD_MODE}" \ No newline at end of file diff --git a/scripts/generate-stub-package-json.cjs b/scripts/generate-stub-package-json.cjs new file mode 100755 index 0000000..a513e27 --- /dev/null +++ b/scripts/generate-stub-package-json.cjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const { version } = JSON.parse(fs.readFileSync("package.json")); +fs.writeFileSync( + "dist-cjs/package.json", + // Just version is enough + JSON.stringify({ version }) +); \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..4a3103e --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +. 'scripts/preamble.sh' + +yarn install "$@" + +cp .eslintrc.library.cjs "${TYRAS_LIB_DIR}/.eslintrc.cjs" +cp .eslintrc.out.cjs "${TYRAS_LIB_DIR}" +cp .eslintrc.out-ts.cjs "${TYRAS_LIB_DIR}" +cp tsconfig.library.json "${TYRAS_LIB_DIR}/tsconfig.json" +cp tsconfig.build.json "${TYRAS_LIB_DIR}" +cp tsconfig.out.json "${TYRAS_LIB_DIR}" \ No newline at end of file diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..578147a --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +. 'scripts/preamble.sh' + +yarn run lint diff --git a/scripts/preamble.sh b/scripts/preamble.sh new file mode 100755 index 0000000..f1412da --- /dev/null +++ b/scripts/preamble.sh @@ -0,0 +1,29 @@ +set -e + +TYRAS_ROOT_DIR="$(pwd)" + +# Always use Alpine-based image +TYRAS_NODE_VERSION="$(cat "${TYRAS_ROOT_DIR}/versions/node")-alpine" + +TYRAS_LIB_DIR="$1" + +if [ -z "${TYRAS_LIB_DIR}" ]; then + echo 'Please specify library directory as argument' 1>&2 + exit 1 +fi + +shift + +yarn () +{ + docker run \ + --rm \ + -t \ + --volume "${TYRAS_ROOT_DIR}:${TYRAS_ROOT_DIR}:rw" \ + --entrypoint yarn \ + --workdir "${TYRAS_ROOT_DIR}/${TYRAS_LIB_DIR}" \ + --env YARN_CACHE_FOLDER="${TYRAS_ROOT_DIR}/.yarn" \ + --env NODE_PATH="${TYRAS_ROOT_DIR}/${TYRAS_LIB_DIR}/node_modules" \ + "node:${TYRAS_NODE_VERSION}" \ + "$@" +} diff --git a/scripts/remove-empty-js-files.cjs b/scripts/remove-empty-js-files.cjs new file mode 100755 index 0000000..e4c5356 --- /dev/null +++ b/scripts/remove-empty-js-files.cjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const glob = require("glob"); + +glob + // Find all CommonJS files + .sync("dist-cjs/**/*.js") + // Read their contents while remembering the file path + .map((file) => ({ file, contents: fs.readFileSync(file, "utf8") })) + // Check whether the contents match what is emitted by a file without anything runtime-living to export + .filter(({ contents }) => contents === '"use strict";\nObject.defineProperty(exports, "__esModule", { value: true });\n') + // Delete the files + .map(({ file }) => fs.unlinkSync(file)) + ; + +glob + // Find all ESM files + .sync("dist-esm/**/*.js") + // Read their contents while remembering the file path + .map((file) => ({ file, contents: fs.readFileSync(file, "utf8") })) + // Check whether the contents match what is emitted by a file without anything runtime-living to export + .filter(({ contents }) => contents === 'export {};\n') + // Delete the files + .map(({ file }) => fs.unlinkSync(file)) + ; diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..ac36eaa --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +. 'scripts/preamble.sh' + +TYRAS_TEST_MODE="$1" +if [ "${TYRAS_TEST_MODE}" = "run" ] || [ "${TYRAS_TEST_MODE}" = "coverage" ]; then + if [ "$#" -gt 0 ]; then + shift + fi +else + TYRAS_TEST_MODE='run' +fi + +yarn run "test:${TYRAS_TEST_MODE}" "$@" diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..89407e7 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +// TS config file to use to compile /src/**/*.ts files in CI. +{ + "extends": "./tsconfig.json", + "exclude": [ + // Otherwise tsc will produce error: + // node_modules/@ava/get-port/dist/source/index.d.ts:1:23 - error TS1452: 'resolution-mode' assertions are only supported when `moduleResolution` is `node16` or `nodenext`. + // + // 1 /// + // + // Also, this way, we will not compile any of the tests files, thus avoiding need to move them around in CI pipeline. + "src/**/__test__/*" + ], + "compilerOptions": { + // We don't want dangling // eslint-disable-xyz comments, as that will cause errors during formatting output .[m]js files. + "removeComments": true + }, +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1905d26 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +// Base TS config file for all other TS configs in repo. +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "node", + "lib": [ + "ES2022" + ], + "target": "ES2022", + "esModuleInterop": true, + // No code minimization/uglification is happening, thus preserving source maps does not bring much value. + // Furthermore, because .js and .mjs files will reside in the same directory, there will be a little hassle on the mapping file names + their refs in source. + "sourceMap": false, + "strict": true, + "exactOptionalPropertyTypes": true, + // We export whole src folder, so no need to include declaration files to dist folder. + "declaration": false, + "noErrorTruncation": true, + "incremental": true, + }, + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node" + }, +} \ No newline at end of file diff --git a/tsconfig.library.json b/tsconfig.library.json new file mode 100644 index 0000000..8565a1d --- /dev/null +++ b/tsconfig.library.json @@ -0,0 +1,12 @@ +// TS config for the /src/**/*.ts files. +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./build/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*" + ], +} \ No newline at end of file diff --git a/tsconfig.out.json b/tsconfig.out.json new file mode 100644 index 0000000..5e8b5cf --- /dev/null +++ b/tsconfig.out.json @@ -0,0 +1,11 @@ +// TS config for formatting the resulting .d.ts files (/dist-ts/**/*.d.ts) that end up in NPM package for typing information. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./dist-ts", + "noEmit": true + }, + "include": [ + "./dist-ts/**/*" + ] +} \ No newline at end of file diff --git a/versions/node b/versions/node new file mode 100644 index 0000000..25bf17f --- /dev/null +++ b/versions/node @@ -0,0 +1 @@ +18 \ No newline at end of file diff --git a/versions/npm-publish b/versions/npm-publish new file mode 100644 index 0000000..3c80e4f --- /dev/null +++ b/versions/npm-publish @@ -0,0 +1 @@ +1.4.3 \ No newline at end of file