diff --git a/.0pdd.yml b/.0pdd.yml new file mode 100644 index 0000000..e82b6e7 --- /dev/null +++ b/.0pdd.yml @@ -0,0 +1,10 @@ +# 0pdd configuration file. +# See for details: https://github.com/yegor256/0pdd +errors: + - slava.semushin+0pdd@gmail.com +alerts: + suppress: + - on-scope +format: + - short-title + - title-length=120 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..aac594e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +# See for details: +# - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates +# - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#scheduleinterval + schedule: + interval: "daily" + time: "06:00" + timezone: "Asia/Novosibirsk" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#commit-message + commit-message: + prefix: "ci" + assignees: [ "php-coder" ] + labels: [ "kind/dependency-update", "area/ci" ] + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#rebase-strategy + rebase-strategy: "disabled" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#open-pull-requests-limit + open-pull-requests-limit: 1 + + - package-ecosystem: "npm" + directory: "/" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#scheduleinterval + schedule: + interval: "daily" + time: "06:00" + timezone: "Asia/Novosibirsk" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#commit-message + commit-message: + prefix: "build" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#versioning-strategy + versioning-strategy: "increase" + assignees: [ "php-coder" ] + labels: [ "kind/dependency-update" ] + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#rebase-strategy + rebase-strategy: "disabled" + # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#open-pull-requests-limit + open-pull-requests-limit: 1 diff --git a/.github/workflows/generate-go-app.yml b/.github/workflows/generate-go-app.yml new file mode 100644 index 0000000..b74fc9e --- /dev/null +++ b/.github/workflows/generate-go-app.yml @@ -0,0 +1,51 @@ +name: Generate Golang app + +on: + push: + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions +permissions: + # NOTE: actions/upload-artifact makes no use of permissions + # See https://github.com/actions/upload-artifact/issues/197#issuecomment-832279436 + contents: read # for "git clone" + +defaults: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrun + run: + # Enable fail-fast behavior using set -eo pipefail + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +jobs: + generate-app: + name: Generate app + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on + runs-on: ubuntu-22.04 + steps: + + - name: Clone source code + uses: actions/checkout@v4.2.2 # https://github.com/actions/checkout + with: + # Whether to configure the token or SSH key with the local git config. Default: true + persist-credentials: false + + - name: Setup NodeJS + uses: actions/setup-node@v4.4.0 # https://github.com/actions/setup-node + with: + node-version: 18 + cache: 'npm' + + - name: Install project dependencies + run: npm ci --no-audit --no-fund # https://docs.npmjs.com/cli/v8/commands/npm-ci + + - name: Generate Golang + Chi application + run: npm run example:go + + - name: Check whether all modified files have been committed + run: >- + MODIFIED_FILES="$(git status --short)"; + if [ -n "$MODIFIED_FILES" ]; then + echo >&2 "ERROR: the following generated files have not been committed:"; + echo >&2 "$MODIFIED_FILES"; + exit 1; + fi diff --git a/.github/workflows/generate-js-app.yml b/.github/workflows/generate-js-app.yml new file mode 100644 index 0000000..46f3369 --- /dev/null +++ b/.github/workflows/generate-js-app.yml @@ -0,0 +1,51 @@ +name: Generate JavaScript app + +on: + push: + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions +permissions: + # NOTE: actions/upload-artifact makes no use of permissions + # See https://github.com/actions/upload-artifact/issues/197#issuecomment-832279436 + contents: read # for "git clone" + +defaults: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrun + run: + # Enable fail-fast behavior using set -eo pipefail + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +jobs: + generate-app: + name: Generate app + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on + runs-on: ubuntu-22.04 + steps: + + - name: Clone source code + uses: actions/checkout@v4.2.2 # https://github.com/actions/checkout + with: + # Whether to configure the token or SSH key with the local git config. Default: true + persist-credentials: false + + - name: Setup NodeJS + uses: actions/setup-node@v4.4.0 # https://github.com/actions/setup-node + with: + node-version: 18 + cache: 'npm' + + - name: Install project dependencies + run: npm ci --no-audit --no-fund # https://docs.npmjs.com/cli/v8/commands/npm-ci + + - name: Generate JavaScript + Express application + run: npm run example:js + + - name: Check whether all modified files have been committed + run: >- + MODIFIED_FILES="$(git status --short)"; + if [ -n "$MODIFIED_FILES" ]; then + echo >&2 "ERROR: the following generated files have not been committed:"; + echo >&2 "$MODIFIED_FILES"; + exit 1; + fi diff --git a/.github/workflows/generate-python-app.yml b/.github/workflows/generate-python-app.yml new file mode 100644 index 0000000..cbe2bfd --- /dev/null +++ b/.github/workflows/generate-python-app.yml @@ -0,0 +1,51 @@ +name: Generate Python app + +on: + push: + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions +permissions: + # NOTE: actions/upload-artifact makes no use of permissions + # See https://github.com/actions/upload-artifact/issues/197#issuecomment-832279436 + contents: read # for "git clone" + +defaults: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrun + run: + # Enable fail-fast behavior using set -eo pipefail + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +jobs: + generate-app: + name: Generate app + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on + runs-on: ubuntu-22.04 + steps: + + - name: Clone source code + uses: actions/checkout@v4.2.2 # https://github.com/actions/checkout + with: + # Whether to configure the token or SSH key with the local git config. Default: true + persist-credentials: false + + - name: Setup NodeJS + uses: actions/setup-node@v4.4.0 # https://github.com/actions/setup-node + with: + node-version: 18 + cache: 'npm' + + - name: Install project dependencies + run: npm ci --no-audit --no-fund # https://docs.npmjs.com/cli/v8/commands/npm-ci + + - name: Generate Python + FastAPI application + run: npm run example:py + + - name: Check whether all modified files have been committed + run: >- + MODIFIED_FILES="$(git status --short)"; + if [ -n "$MODIFIED_FILES" ]; then + echo >&2 "ERROR: the following generated files have not been committed:"; + echo >&2 "$MODIFIED_FILES"; + exit 1; + fi diff --git a/.github/workflows/generate-ts-app.yml b/.github/workflows/generate-ts-app.yml new file mode 100644 index 0000000..a312862 --- /dev/null +++ b/.github/workflows/generate-ts-app.yml @@ -0,0 +1,51 @@ +name: Generate TypeScript app + +on: + push: + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions +permissions: + # NOTE: actions/upload-artifact makes no use of permissions + # See https://github.com/actions/upload-artifact/issues/197#issuecomment-832279436 + contents: read # for "git clone" + +defaults: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrun + run: + # Enable fail-fast behavior using set -eo pipefail + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +jobs: + generate-app: + name: Generate app + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on + runs-on: ubuntu-22.04 + steps: + + - name: Clone source code + uses: actions/checkout@v4.2.2 # https://github.com/actions/checkout + with: + # Whether to configure the token or SSH key with the local git config. Default: true + persist-credentials: false + + - name: Setup NodeJS + uses: actions/setup-node@v4.4.0 # https://github.com/actions/setup-node + with: + node-version: 18 + cache: 'npm' + + - name: Install project dependencies + run: npm ci --no-audit --no-fund # https://docs.npmjs.com/cli/v8/commands/npm-ci + + - name: Generate TypeScript + Express application + run: npm run example:ts + + - name: Check whether all modified files have been committed + run: >- + MODIFIED_FILES="$(git status --short)"; + if [ -n "$MODIFIED_FILES" ]; then + echo >&2 "ERROR: the following generated files have not been committed:"; + echo >&2 "$MODIFIED_FILES"; + exit 1; + fi diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..2fe8a70 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,137 @@ +name: Integration Tests + +on: + push: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onworkflow_dispatch + workflow_dispatch: + +# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#permissions +permissions: + contents: read # for "git clone" + +defaults: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#defaultsrun + run: + # Enable fail-fast behavior using set -eo pipefail + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference + shell: bash + +jobs: + run-integration-tests: + name: Integration Tests + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idruns-on + runs-on: ubuntu-22.04 + # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs + strategy: + # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#handling-failures + fail-fast: false + matrix: + # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#example-adding-configurations + include: + # "docker-service-name" must match "services.$name" from docker-compose.yaml + # "database-service-name" must match "services.$name" from docker-compose.yaml + # "application-port" must match "services.$name.environment:PORT" from docker-compose.yaml + - docker-service-name: 'express-js' + database-service-name: 'mysql' + application-port: 3010 + skip_500_error_testing: false + - docker-service-name: 'express-ts' + database-service-name: 'mysql' + application-port: 3020 + skip_500_error_testing: false + - docker-service-name: 'chi' + database-service-name: 'mysql' + application-port: 3030 + skip_500_error_testing: true + - docker-service-name: 'fastapi' + database-service-name: 'postgres' + application-port: 4040 + skip_500_error_testing: false + env: + # Prevent interference between builds by setting the project name to a unique value. Otherwise + # "docker compose down" has been stopping containers (especially database) from other builds. + # https://docs.docker.com/compose/project-name/ + # https://docs.docker.com/compose/environment-variables/envvars/#compose_project_name + COMPOSE_PROJECT_NAME: ${{ matrix.docker-service-name }} + steps: + + - name: Clone source code + uses: actions/checkout@v4.2.2 # https://github.com/actions/checkout + with: + # Whether to configure the token or SSH key with the local git config. Default: true + persist-credentials: false + + - name: Show docker version + run: docker version + + - name: Show docker compose version + run: docker compose version + + - name: Start containers + working-directory: docker + run: >- + docker compose up \ + --build \ + --detach \ + --wait \ + --quiet-pull \ + ${{ matrix.docker-service-name }} + + - name: Show container statuses + if: '!cancelled()' + working-directory: docker + run: docker compose ps + + - name: Install mise to install Hurl + uses: jdx/mise-action@v2.4.0 # https://github.com/jdx/mise-action + with: + version: 2025.7.8 # [default: latest] mise version to install + install: true # [default: true] run `mise install` + cache: true # [default: true] cache mise using GitHub's cache + log_level: info # [default: info] log level + working_directory: tests # [default: .] directory to run mise in + env: + # Workaround: don't install some dependencies that we don't use (go, node, python) + # See: https://github.com/jdx/mise-action/issues/183 + # https://mise.jdx.dev/configuration/settings.html#disable_tools + MISE_DISABLE_TOOLS: go,node,python + + - name: Show Hurl version + working-directory: tests + run: hurl --version + + - name: Run integration tests + working-directory: tests + run: >- + hurl \ + --error-format long \ + --variable SERVER_URL=http://127.0.0.1:${{ matrix.application-port }} \ + --variable skip_500_error_testing=${{ matrix.skip_500_error_testing }} \ + --test + + - name: Show application logs + if: failure() + working-directory: docker + run: >- + docker compose logs \ + --no-log-prefix \ + --timestamps \ + ${{ matrix.docker-service-name }} + + - name: Show database logs + if: failure() + working-directory: docker + run: >- + docker compose logs \ + --no-log-prefix \ + --timestamps \ + ${{ matrix.database-service-name }} + + - name: Stop containers + if: always() + working-directory: docker + run: >- + docker compose down \ + --volumes \ + --remove-orphans \ + --rmi local diff --git a/.gitignore b/.gitignore index a6e3b0d..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1 @@ node_modules/ - -# RobotFramework reports -tests/log.html -tests/output.xml -tests/report.html diff --git a/README.md b/README.md index 47a4c84..9e0f2d0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Query To App Generates the endpoints (or a whole app) from a mapping (SQL query -> URL) ->:warning: This is a proof of concept at this moment. Until it reaches a stable version, it might (and will) break a compatibility. +> [!WARNING] +> This is a proof of concept at this moment. Until it reaches a stable version, it might (and will) break a compatibility. # How to use @@ -14,45 +15,59 @@ Generates the endpoints (or a whole app) from a mapping (SQL query -> URL) 1. Create a mapping file `endpoints.yaml` ```console $ vim endpoints.yaml - - path: /v1/categories/count - get: SELECT COUNT(*) AS counter FROM categories - - path: /v1/categories - get_list: >- - SELECT id, name, name_ru, slug - FROM categories - post: >- - INSERT INTO categories(name, slug, created_at, created_by, updated_at, updated_by) - VALUES (:b.name, :b.slug, NOW(), :b.userId, NOW(), :b.userId) + get_list: + query: >- + SELECT id, name, name_ru, slug + FROM categories + LIMIT :q.limit + post: + query: >- + INSERT INTO categories(name, slug, created_at, created_by, updated_at, updated_by) + VALUES (:b.name, :b.slug, NOW(), :b.user_id, NOW(), :b.user_id) - path: /v1/categories/:categoryId - get: >- - SELECT id, name, name_ru, slug - FROM categories - WHERE id = :p.categoryId - put: >- - UPDATE categories - SET name = :b.name, name_ru = :b.nameRu, slug = :b.slug, updated_at = NOW(), updated_by = :b.userId - WHERE id = :p.categoryId - delete: >- - DELETE - FROM categories - WHERE id = :p.categoryId + get: + query: >- + SELECT id, name, name_ru, slug + FROM categories + WHERE id = :p.categoryId + put: + query: >- + UPDATE categories + SET name = :b.name, name_ru = :b.name_ru, slug = :b.slug, updated_at = NOW(), updated_by = :b.user_id + WHERE id = :p.categoryId + delete: + query: >- + DELETE + FROM categories + WHERE id = :p.categoryId ``` - Note that the queries use a little unusual named parameters: `:b.name`, `:p.categoryId`, etc The prefixes `b` (body) and `p` (path) are used here in order to bind to parameters from the appropriate sources. The prefixes are needed only during code generation and they will absent from the resulted code. + Note that the queries use a little unusual named parameters: `:b.name`, `:p.categoryId`, etc The prefixes `q` (query), `b` (body) and `p` (path) are used here in order to bind to parameters from the appropriate sources. The prefixes are needed only during code generation and they will absent from the resulted code. 1. Generate code - ```console - $ npx query2app - ``` - An example of generated code can be inspect at [examples/js](examples/js) directory. +
+ Example commands + + | Language | Command | Generated files | Dependencies | + | -----------| ----------------------------| ---------------------------| ------------ | + | JavaScript | `npx query2app --lang js` | [`app.js`](examples/js/express/mysql/app.js)
[`routes.js`](examples/js/express/mysql/routes.js)
[`package.json`](examples/js/express/mysql/package.json)
[`Dockerfile`](examples/js/express/mysql/Dockerfile) | [`express`](https://www.npmjs.com/package/express)
[`mysql`](https://www.npmjs.com/package/mysql) | + | TypeScript | `npx query2app --lang ts` | [`app.ts`](examples/ts/express/mysql/app.ts)
[`routes.ts`](examples/ts/express/mysql/routes.ts)
[`package.json`](examples/ts/express/mysql/package.json)
[`tsconfig.json`](examples/ts/express/mysql/tsconfig.json)
[`Dockerfile`](examples/ts/express/mysql/Dockerfile) | [`express`](https://www.npmjs.com/package/express)
[`mysql`](https://www.npmjs.com/package/mysql) | + | Golang | `npx query2app --lang go` | [`app.go`](examples/go/chi/mysql/app.go)
[`routes.go`](examples/go/chi/mysql/routes.go)
[`go.mod`](examples/go/chi/mysql/go.mod)
[`Dockerfile`](examples/go/chi/mysql/Dockerfile) | [`go-chi/chi`](https://github.com/go-chi/chi)
[`go-sql-driver/mysql`](https://github.com/go-sql-driver/mysql)
[`jmoiron/sqlx`](https://github.com/jmoiron/sqlx) | + | Python | `npx query2app --lang python` | [`app.py`](examples/python/fastapi/postgres/app.py)
[`db.py`](examples/python/fastapi/postgres/db.py)
[`routes.py`](examples/python/fastapi/postgres/routes.py)
[`requirements.txt`](examples/python/fastapi/postgres/requirements.txt)
[`Dockerfile`](examples/python/fastapi/postgres/Dockerfile) | [FastAPI](https://github.com/tiangolo/fastapi)
[Uvicorn](https://www.uvicorn.org)
[psycopg2](https://pypi.org/project/psycopg2/) | +
1. Run the application - ```console - $ npm install - $ export DB_NAME=my-db DB_USER=my-user DB_PASSWORD=my-password - $ npm start - ``` +
+ Example commands + + | Language | Commands | + | -----------| ---------| + | JavaScript |
$ npm install
$ export DB_NAME=my-db DB_USER=my-user DB_PASSWORD=my-password
$ npm start
| + | TypeScript |
$ npm install
$ npm run build
$ export DB_NAME=my-db DB_USER=my-user DB_PASSWORD=my-password
$ npm start
| + | Golang |
$ export DB_NAME=my-db DB_USER=my-user DB_PASSWORD=my-password
$ go run *.go
or
$ go build -o app
$ ./app
| + | Python |
$ pip install -r requirements.txt
$ export DB_NAME=my-db DB_USER=my-user DB_PASSWORD=my-password
$ uvicorn app:app --port 3000
| + --- :bulb: **NOTE** @@ -67,28 +82,37 @@ Generates the endpoints (or a whole app) from a mapping (SQL query -> URL) * `DB_HOST` a database host (defaults to `localhost`) --- +
1. Test that it works +
+ Examples for curl + ```console - $ curl http://localhost:3000/v1/categories/count - {"counter":0} - $ curl -i -H 'Content-Type: application/json' -d '{"name":"Sport","nameRu":"Спорт","slug":"sport","userId":100}' http://localhost:3000/v1/categories + $ curl -i http://localhost:3000/v1/categories \ + --json '{"name":"Sport","name_ru":"Спорт","slug":"sport","user_id":100}' HTTP/1.1 204 No Content ETag: W/"a-bAsFyilMr4Ra1hIU5PyoyFRunpI" Date: Wed, 15 Jul 2020 18:06:33 GMT Connection: keep-alive + $ curl http://localhost:3000/v1/categories [{"id":1,"name":"Sport","name_ru":"Спорт","slug":"sport"}] - $ curl -i -H 'Content-Type: application/json' -d '{"name":"Fauna","nameRu":"Фауна","slug":"fauna","userId":101}' -X PUT http://localhost:3000/v1/categories/1 + + $ curl -i -X PUT http://localhost:3000/v1/categories/1 \ + --json '{"name":"Fauna","name_ru":"Фауна","slug":"fauna","user_id":101}' HTTP/1.1 204 No Content ETag: W/"a-bAsFyilMr4Ra1hIU5PyoyFRunpI" Date: Wed, 15 Jul 2020 18:06:34 GMT Connection: keep-alive + $ curl http://localhost:3000/v1/categories/1 {"id":1,"name":"Fauna","name_ru":"Фауна","slug":"fauna"} + $ curl -i -X DELETE http://localhost:3000/v1/categories/1 HTTP/1.1 204 No Content ETag: W/"a-bAsFyilMr4Ra1hIU5PyoyFRunpI" Date: Wed, 15 Jul 2020 18:06:35 GMT Connection: keep-alive ``` +
diff --git a/docker/categories.sql b/docker/categories.mysql.sql similarity index 95% rename from docker/categories.sql rename to docker/categories.mysql.sql index bffc83a..78ff33a 100644 --- a/docker/categories.sql +++ b/docker/categories.mysql.sql @@ -3,6 +3,7 @@ CREATE TABLE `categories` ( `name` varchar(50) NOT NULL, `name_ru` varchar(50) DEFAULT NULL, `slug` varchar(50) NOT NULL, + `hidden` boolean, `created_at` datetime NOT NULL, `created_by` int(11) NOT NULL, `updated_at` datetime NOT NULL, diff --git a/docker/categories.postgres.sql b/docker/categories.postgres.sql new file mode 100644 index 0000000..35335b1 --- /dev/null +++ b/docker/categories.postgres.sql @@ -0,0 +1,15 @@ +CREATE TABLE categories ( + id bigserial NOT NULL, + name varchar(50) NOT NULL, + name_ru varchar(50) DEFAULT NULL, + slug varchar(50) NOT NULL, + hidden boolean, + created_at timestamp NOT NULL, + created_by bigint NOT NULL, + updated_at timestamp NOT NULL, + updated_by bigint NOT NULL, + PRIMARY KEY (id), + UNIQUE (name), + UNIQUE (slug), + UNIQUE (name_ru) +); diff --git a/docker/docker-compose.local.yaml b/docker/docker-compose.local.yaml new file mode 100644 index 0000000..9d72791 --- /dev/null +++ b/docker/docker-compose.local.yaml @@ -0,0 +1,18 @@ +# Customize configuration from docker-compose.yaml to run services locally. +# +# In order to get the effective configuration, run +# docker compose -f docker-compose.yaml -f docker-compose.local.yaml config +# +# Usage: +# +# docker compose -f docker-compose.yaml -f docker-compose.local.yaml up -d +# + +services: + mysql: + ports: + - '3306:3306' + + postgres: + ports: + - '5432:5432' diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index ed4b0c8..44d2420 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,12 +1,13 @@ -# Usage example: +# Usage examples: # -# $ docker-compose up -d -# $ docker-compose exec mysql mysql -u test -ptest test +# docker compose up -d +# docker compose exec mysql mysql -u test -ptest test -e 'SELECT * FROM categories' +# docker compose exec postgres psql -U test -c 'SELECT * FROM categories' # -version: '3' services: mysql: + # https://hub.docker.com/_/mysql image: mysql:5.7.20 user: mysql:mysql environment: @@ -14,8 +15,87 @@ services: - MYSQL_USER=test - MYSQL_PASSWORD=test - MYSQL_DATABASE=test - ports: - - '3306:3306' volumes: - - ./categories.sql:/docker-entrypoint-initdb.d/categories.sql + - ./categories.mysql.sql:/docker-entrypoint-initdb.d/categories.sql + healthcheck: + # Specifying "MYSQL_PWD" variable suppresses "Warning: Using a password on the command line interface can be insecure" + # Attention: MYSQL_PWD is deprecated as of MySQL 8.0; expect it to be removed in a future version of MySQL + # Note: double dollar sign protects variables from docker compose interpolation + test: "MYSQL_PWD=$$MYSQL_PASSWORD mysql --user=$$MYSQL_USER --silent --execute 'SELECT \"OK\" AS result'" + interval: 1s + timeout: 5s + retries: 10 + start_period: 1s + + postgres: + # https://hub.docker.com/_/postgres + image: postgres:12-bookworm + environment: + - POSTGRES_USER=test + - POSTGRES_PASSWORD=test + - POSTGRES_DB=test + volumes: + - ./categories.postgres.sql:/docker-entrypoint-initdb.d/categories.sql + healthcheck: + # Note: double dollar sign protects variables from docker compose interpolation + test: "pg_isready --user $$POSTGRES_USER --quiet --timeout 0" + interval: 1s + timeout: 5s + retries: 10 + start_period: 1s + express-js: + build: ../examples/js/express/mysql + environment: + - DB_NAME=test + - DB_USER=test + - DB_PASSWORD=test + - DB_HOST=mysql # defaults to localhost + - PORT=3010 # defaults to 3000 + ports: + - '3010:3010' + depends_on: + mysql: + condition: service_healthy + + express-ts: + build: ../examples/ts/express/mysql + environment: + - DB_NAME=test + - DB_USER=test + - DB_PASSWORD=test + - DB_HOST=mysql # defaults to localhost + - PORT=3020 # defaults to 3000 + ports: + - '3020:3020' + depends_on: + mysql: + condition: service_healthy + + chi: + build: ../examples/go/chi/mysql + environment: + - DB_NAME=test + - DB_USER=test + - DB_PASSWORD=test + - DB_HOST=mysql # defaults to localhost + - PORT=3030 # defaults to 3000 + ports: + - '3030:3030' + depends_on: + mysql: + condition: service_healthy + + fastapi: + build: ../examples/python/fastapi/postgres + environment: + - DB_NAME=test + - DB_USER=test + - DB_PASSWORD=test + - DB_HOST=postgres # defaults to localhost + - PORT=4040 # defaults to 3000 + ports: + - '4040:4040' + depends_on: + postgres: + condition: service_healthy diff --git a/examples/go/chi/mysql/Dockerfile b/examples/go/chi/mysql/Dockerfile new file mode 100644 index 0000000..5d60014 --- /dev/null +++ b/examples/go/chi/mysql/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.14 AS builder +WORKDIR /opt +COPY go.mod ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app + +FROM scratch +WORKDIR /opt/app +COPY --from=builder /opt/app . +CMD [ "/opt/app/app" ] diff --git a/examples/go/chi/mysql/app.go b/examples/go/chi/mysql/app.go new file mode 100644 index 0000000..1702e59 --- /dev/null +++ b/examples/go/chi/mysql/app.go @@ -0,0 +1,54 @@ +package main + +import "fmt" +import "net/http" +import "os" +import "github.com/go-chi/chi" +import "github.com/jmoiron/sqlx" + +import _ "github.com/go-sql-driver/mysql" + +func main() { + mapper := func(name string) string { + value := os.Getenv(name) + switch name { + case "DB_HOST": + if value == "" { + value = "localhost" + } + case "DB_NAME", "DB_USER", "DB_PASSWORD": + if value == "" { + fmt.Fprintf(os.Stderr, "%s env variable is not set or empty\n", name) + os.Exit(1) + } + } + return value + } + + dsn := os.Expand("${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/${DB_NAME}", mapper) + db, err := sqlx.Open("mysql", dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "sqlx.Open failed: %v\n", err) + os.Exit(1) + } + defer db.Close() + + if err = db.Ping(); err != nil { + fmt.Fprintf(os.Stderr, "Ping failed: could not connect to database: %v\n", err) + os.Exit(1) + } + + r := chi.NewRouter() + registerRoutes(r, db) + registerCustomRoutes(r, db) + + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + fmt.Println("Listen on " + port) + err = http.ListenAndServe(":"+port, r) + fmt.Fprintf(os.Stderr, "ListenAndServe failed: %v\n", err) + os.Exit(1) +} diff --git a/examples/go/chi/mysql/custom_routes.go b/examples/go/chi/mysql/custom_routes.go new file mode 100644 index 0000000..9b9abc9 --- /dev/null +++ b/examples/go/chi/mysql/custom_routes.go @@ -0,0 +1,21 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi" + "github.com/jmoiron/sqlx" +) + +func registerCustomRoutes(r chi.Router, db *sqlx.DB) { + + r.Get("/custom/route", func(w http.ResponseWriter, r *http.Request) { + result := map[string]bool{ + "custom": true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + }) + +} diff --git a/examples/go/chi/mysql/endpoints.yaml b/examples/go/chi/mysql/endpoints.yaml new file mode 120000 index 0000000..9e6d040 --- /dev/null +++ b/examples/go/chi/mysql/endpoints.yaml @@ -0,0 +1 @@ +../../../js/express/mysql/endpoints.yaml \ No newline at end of file diff --git a/examples/go/chi/mysql/go.mod b/examples/go/chi/mysql/go.mod new file mode 100644 index 0000000..0df3f33 --- /dev/null +++ b/examples/go/chi/mysql/go.mod @@ -0,0 +1,9 @@ +module main + +go 1.14 + +require ( + github.com/go-chi/chi v4.1.2+incompatible + github.com/go-sql-driver/mysql v1.5.0 + github.com/jmoiron/sqlx v1.2.0 +) diff --git a/examples/go/chi/mysql/routes.go b/examples/go/chi/mysql/routes.go new file mode 100644 index 0000000..d468a64 --- /dev/null +++ b/examples/go/chi/mysql/routes.go @@ -0,0 +1,277 @@ +package main + +import "database/sql" +import "encoding/json" +import "fmt" +import "io" +import "net/http" +import "os" +import "strconv" +import "github.com/go-chi/chi" +import "github.com/jmoiron/sqlx" + +type CounterDto struct { + Counter *int `json:"counter" db:"counter"` +} + +type CategoryDto struct { + Id *int `json:"id" db:"id"` + Name *string `json:"name" db:"name"` + NameRu *string `json:"name_ru" db:"name_ru"` + Slug *string `json:"slug" db:"slug"` + Hidden *bool `json:"hidden" db:"hidden"` +} + +type CreateCategoryDto struct { + Name *string `json:"name" db:"name"` + NameRu *string `json:"name_ru" db:"name_ru"` + Slug *string `json:"slug" db:"slug"` + Hidden *bool `json:"hidden" db:"hidden"` + UserId *int `json:"user_id" db:"user_id"` +} + +func parseBoolean(value string) bool { + boolValue, err := strconv.ParseBool(value) + if err != nil { + boolValue = false + } + return boolValue +} + +func registerRoutes(r chi.Router, db *sqlx.DB) { + + r.Get("/v1/categories/count", func(w http.ResponseWriter, r *http.Request) { + var result CounterDto + err := db.Get( + &result, + "SELECT COUNT(*) AS counter FROM categories") + switch err { + case sql.ErrNoRows: + w.WriteHeader(http.StatusNotFound) + case nil: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + default: + fmt.Fprintf(os.Stderr, "Get failed: %v\n", err) + internalServerError(w) + } + }) + + r.Get("/v1/collections/{collectionId}/categories/count", func(w http.ResponseWriter, r *http.Request) { + stmt, err := db.PrepareNamed( + `SELECT COUNT(DISTINCT s.category_id) AS counter + FROM collections_series cs + JOIN series s + ON s.id = cs.series_id + WHERE cs.collection_id = :collectionId`) + if err != nil { + fmt.Fprintf(os.Stderr, "PrepareNamed failed: %v\n", err) + internalServerError(w) + return + } + + var result CounterDto + args := map[string]interface{}{ + "collectionId": chi.URLParam(r, "collectionId"), + } + err = stmt.Get(&result, args) + switch err { + case sql.ErrNoRows: + w.WriteHeader(http.StatusNotFound) + case nil: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + default: + fmt.Fprintf(os.Stderr, "Get failed: %v\n", err) + internalServerError(w) + } + }) + + r.Get("/v1/categories", func(w http.ResponseWriter, r *http.Request) { + result := []CategoryDto{} + err := db.Select( + &result, + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories`) + switch err { + case sql.ErrNoRows: + w.WriteHeader(http.StatusNotFound) + case nil: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + default: + fmt.Fprintf(os.Stderr, "Select failed: %v\n", err) + internalServerError(w) + } + }) + + r.Post("/v1/categories", func(w http.ResponseWriter, r *http.Request) { + var body CreateCategoryDto + json.NewDecoder(r.Body).Decode(&body) + + args := map[string]interface{}{ + "name": body.Name, + "name_ru": body.NameRu, + "slug": body.Slug, + "hidden": body.Hidden, + "user_id": body.UserId, + } + _, err := db.NamedExec( + `INSERT + INTO categories + ( name + , name_ru + , slug + , hidden + , created_at + , created_by + , updated_at + , updated_by + ) + VALUES + ( :name + , :name_ru + , :slug + , :hidden + , CURRENT_TIMESTAMP + , :user_id + , CURRENT_TIMESTAMP + , :user_id + )`, + args, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "NamedExec failed: %v\n", err) + internalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + r.Get("/v1/categories/search", func(w http.ResponseWriter, r *http.Request) { + stmt, err := db.PrepareNamed( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE hidden = :hidden`) + if err != nil { + fmt.Fprintf(os.Stderr, "PrepareNamed failed: %v\n", err) + internalServerError(w) + return + } + + result := []CategoryDto{} + args := map[string]interface{}{ + "hidden": parseBoolean(r.URL.Query().Get("hidden")), + } + err = stmt.Select(&result, args) + switch err { + case sql.ErrNoRows: + w.WriteHeader(http.StatusNotFound) + case nil: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + default: + fmt.Fprintf(os.Stderr, "Select failed: %v\n", err) + internalServerError(w) + } + }) + + r.Get("/v1/categories/{categoryId}", func(w http.ResponseWriter, r *http.Request) { + stmt, err := db.PrepareNamed( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE id = :categoryId`) + if err != nil { + fmt.Fprintf(os.Stderr, "PrepareNamed failed: %v\n", err) + internalServerError(w) + return + } + + var result CategoryDto + args := map[string]interface{}{ + "categoryId": chi.URLParam(r, "categoryId"), + } + err = stmt.Get(&result, args) + switch err { + case sql.ErrNoRows: + w.WriteHeader(http.StatusNotFound) + case nil: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + default: + fmt.Fprintf(os.Stderr, "Get failed: %v\n", err) + internalServerError(w) + } + }) + + r.Put("/v1/categories/{categoryId}", func(w http.ResponseWriter, r *http.Request) { + var body CreateCategoryDto + json.NewDecoder(r.Body).Decode(&body) + + args := map[string]interface{}{ + "name": body.Name, + "name_ru": body.NameRu, + "slug": body.Slug, + "hidden": body.Hidden, + "user_id": body.UserId, + "categoryId": chi.URLParam(r, "categoryId"), + } + _, err := db.NamedExec( + `UPDATE categories + SET name = :name + , name_ru = :name_ru + , slug = :slug + , hidden = :hidden + , updated_at = CURRENT_TIMESTAMP + , updated_by = :user_id + WHERE id = :categoryId`, + args, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "NamedExec failed: %v\n", err) + internalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + + r.Delete("/v1/categories/{categoryId}", func(w http.ResponseWriter, r *http.Request) { + args := map[string]interface{}{ + "categoryId": chi.URLParam(r, "categoryId"), + } + _, err := db.NamedExec( + `DELETE + FROM categories + WHERE id = :categoryId`, + args, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "NamedExec failed: %v\n", err) + internalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) + +} + +func internalServerError(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `{"error":"Internal Server Error"}`) +} diff --git a/examples/js/app.js b/examples/js/app.js deleted file mode 100644 index 4b92f0c..0000000 --- a/examples/js/app.js +++ /dev/null @@ -1,34 +0,0 @@ -const bodyParser = require('body-parser') -const express = require('express') -const mysql = require('mysql') -const routes = require('./routes') - -const app = express() -app.use(bodyParser.json()) - -const pool = mysql.createPool({ - connectionLimit: 2, - host: process.env.DB_HOST || 'localhost', - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - // Support of named placeholders (https://github.com/mysqljs/mysql#custom-format) - queryFormat: function(query, values) { - if (!values) { - return query; - } - return query.replace(/\:(\w+)/g, function(txt, key) { - if (values.hasOwnProperty(key)) { - return this.escape(values[key]); - } - return txt; - }.bind(this)); - } -}) - -routes.register(app, pool) - -const port = process.env.PORT || 3000; -app.listen(port, () => { - console.log(`Listen on ${port}`) -}) diff --git a/examples/js/endpoints.yaml b/examples/js/endpoints.yaml deleted file mode 100644 index 6864eb6..0000000 --- a/examples/js/endpoints.yaml +++ /dev/null @@ -1,59 +0,0 @@ -- path: /v1/categories/count - get: SELECT COUNT(*) AS counter FROM categories - -- path: /v1/collections/:collectionId/categories/count - get: >- - SELECT COUNT(DISTINCT s.category_id) AS counter - FROM collections_series cs - JOIN series s - ON s.id = cs.series_id - WHERE cs.collection_id = :p.collectionId - -- path: /v1/categories - get_list: >- - SELECT id - , name - , name_ru - , slug - FROM categories - post: >- - INSERT - INTO categories - ( name - , name_ru - , slug - , created_at - , created_by - , updated_at - , updated_by - ) - VALUES - ( :b.name - , :b.nameRu - , :b.slug - , NOW() - , :b.userId - , NOW() - , :b.userId - ) - -- path: /v1/categories/:categoryId - get: >- - SELECT id - , name - , name_ru - , slug - FROM categories - WHERE id = :p.categoryId - put: >- - UPDATE categories - SET name = :b.name - , name_ru = :b.nameRu - , slug = :b.slug - , updated_at = NOW() - , updated_by = :b.userId - WHERE id = :p.categoryId - delete: >- - DELETE - FROM categories - WHERE id = :p.categoryId diff --git a/examples/js/express/mysql/Dockerfile b/examples/js/express/mysql/Dockerfile new file mode 100644 index 0000000..efc57fe --- /dev/null +++ b/examples/js/express/mysql/Dockerfile @@ -0,0 +1,8 @@ +FROM node:18-bookworm +WORKDIR /opt/app +COPY package.json ./ +ENV NPM_CONFIG_UPDATE_NOTIFIER=false +RUN npm install --no-audit --no-fund +COPY *.js ./ +USER node +CMD [ "npm", "start" ] diff --git a/examples/js/express/mysql/app.js b/examples/js/express/mysql/app.js new file mode 100644 index 0000000..455a152 --- /dev/null +++ b/examples/js/express/mysql/app.js @@ -0,0 +1,47 @@ +const express = require('express') +const mysql = require('mysql') +const routes = require('./routes') +const custom_routes = require('./custom_routes') + +const app = express() +app.use(express.json()) + +const pool = mysql.createPool({ + connectionLimit: 2, + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + // Support of named placeholders (https://github.com/mysqljs/mysql#custom-format) + queryFormat: function(query, values) { + if (!values) { + return query + } + return query.replace(/\:(\w+)/g, function(matchedSubstring, capturedValue) { + if (values.hasOwnProperty(capturedValue)) { + return this.escape(values[capturedValue]) + } + return matchedSubstring + }.bind(this)) + }, + // Support for conversion from TINYINT(1) to boolean (https://github.com/mysqljs/mysql#custom-type-casting) + typeCast: function(field, next) { + if (field.type === 'TINY' && field.length === 1) { + return field.string() === '1' + } + return next() + } +}) + +routes.register(app, pool) +custom_routes.register(app, pool) + +app.use((error, req, res, next) => { + console.error(error) + res.status(500).json({ "error": "Internal Server Error" }) +}) + +const port = process.env.PORT || 3000 +app.listen(port, () => { + console.log(`Listen on ${port}`) +}) diff --git a/examples/js/express/mysql/custom_routes.js b/examples/js/express/mysql/custom_routes.js new file mode 100644 index 0000000..84c2454 --- /dev/null +++ b/examples/js/express/mysql/custom_routes.js @@ -0,0 +1,11 @@ +exports.register = (app, pool) => { + + app.get('/custom/route', (req, res, next) => { + res.json({ "custom": true }) + }) + + app.get('/custom/exception', (req, res, next) => { + throw new Error('expected err') + }) + +} diff --git a/examples/js/express/mysql/endpoints.yaml b/examples/js/express/mysql/endpoints.yaml new file mode 100644 index 0000000..eb59f3c --- /dev/null +++ b/examples/js/express/mysql/endpoints.yaml @@ -0,0 +1,138 @@ +- path: /v1/categories/count + get: + query: SELECT COUNT(*) AS counter FROM categories + dto: + name: CounterDto + fields: + counter: + type: integer + +- path: /v1/categories/stat + get: + aggregated_queries: + total: SELECT COUNT(*) FROM categories + in_russian: SELECT COUNT(*) FROM categories WHERE name_ru IS NOT NULL + in_english: SELECT COUNT(*) FROM categories WHERE name IS NOT NULL + fully_translated: SELECT COUNT(*) FROM categories WHERE name IS NOT NULL AND name_ru IS NOT NULL + +- path: /v1/collections/:collectionId/categories/count + get: + query: |- + -- Comments before query is allowed + SELECT COUNT(DISTINCT s.category_id) AS counter + -- ... as well as within a query + FROM collections_series cs + JOIN series s + ON s.id = cs.series_id + WHERE cs.collection_id = :p.collectionId + dto: + fields: + counter: + type: integer + +- path: /v1/categories + get_list: + query: >- + SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + dto: + name: CategoryDto + fields: + id: + type: integer + hidden: + type: boolean + post: + query: >- + INSERT + INTO categories + ( name + , name_ru + , slug + , hidden + , created_at + , created_by + , updated_at + , updated_by + ) + VALUES + ( :b.name + , :b.name_ru + , :b.slug + , :b.hidden + , CURRENT_TIMESTAMP + , :b.user_id + , CURRENT_TIMESTAMP + , :b.user_id + ) + dto: + name: CreateCategoryDto + fields: + user_id: + type: integer + hidden: + type: boolean + +- path: /v1/categories/search + get_list: + query: >- + SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE hidden = :q.hidden + dto: + name: CategoryDto + fields: + id: + type: integer + hidden: + type: boolean + params: + query: + hidden: + type: boolean + +- path: /v1/categories/:categoryId + get: + query: >- + SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE id = :p.categoryId + dto: + fields: + id: + type: integer + hidden: + type: boolean + put: + query: >- + UPDATE categories + SET name = :b.name + , name_ru = :b.name_ru + , slug = :b.slug + , hidden = :b.hidden + , updated_at = CURRENT_TIMESTAMP + , updated_by = :b.user_id + WHERE id = :p.categoryId + dto: + fields: + user_id: + type: integer + hidden: + type: boolean + delete: + query: >- + DELETE + FROM categories + WHERE id = :p.categoryId diff --git a/examples/js/package.json b/examples/js/express/mysql/package.json similarity index 76% rename from examples/js/package.json rename to examples/js/express/mysql/package.json index 6dd060b..0d0fdc8 100644 --- a/examples/js/package.json +++ b/examples/js/express/mysql/package.json @@ -1,11 +1,10 @@ { - "name": "js", + "name": "mysql", "version": "1.0.0", "scripts": { "start": "node app.js" }, "dependencies": { - "body-parser": "~1.19.0", "express": "~4.17.1", "mysql": "~2.18.1" } diff --git a/examples/js/express/mysql/routes.js b/examples/js/express/mysql/routes.js new file mode 100644 index 0000000..5e345f4 --- /dev/null +++ b/examples/js/express/mysql/routes.js @@ -0,0 +1,194 @@ +const parseBoolean = (value) => { + return value === 'true' +} + +const register = (app, pool) => { + + app.get('/v1/categories/count', (req, res, next) => { + pool.query( + "SELECT COUNT(*) AS counter FROM categories", + (err, rows, fields) => { + if (err) { + return next(err) + } + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) + } + ) + }) + + app.get('/v1/collections/:collectionId/categories/count', (req, res, next) => { + pool.query( + `SELECT COUNT(DISTINCT s.category_id) AS counter + FROM collections_series cs + JOIN series s + ON s.id = cs.series_id + WHERE cs.collection_id = :collectionId`, + { + "collectionId": req.params.collectionId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) + } + ) + }) + + app.get('/v1/categories', (req, res, next) => { + pool.query( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories`, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.json(rows) + } + ) + }) + + app.post('/v1/categories', (req, res, next) => { + pool.query( + `INSERT + INTO categories + ( name + , name_ru + , slug + , hidden + , created_at + , created_by + , updated_at + , updated_by + ) + VALUES + ( :name + , :name_ru + , :slug + , :hidden + , CURRENT_TIMESTAMP + , :user_id + , CURRENT_TIMESTAMP + , :user_id + )`, + { + "name": req.body.name, + "name_ru": req.body.name_ru, + "slug": req.body.slug, + "hidden": req.body.hidden, + "user_id": req.body.user_id + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) + + app.get('/v1/categories/search', (req, res, next) => { + pool.query( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE hidden = :hidden`, + { + "hidden": parseBoolean(req.query.hidden) + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.json(rows) + } + ) + }) + + app.get('/v1/categories/:categoryId', (req, res, next) => { + pool.query( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE id = :categoryId`, + { + "categoryId": req.params.categoryId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) + } + ) + }) + + app.put('/v1/categories/:categoryId', (req, res, next) => { + pool.query( + `UPDATE categories + SET name = :name + , name_ru = :name_ru + , slug = :slug + , hidden = :hidden + , updated_at = CURRENT_TIMESTAMP + , updated_by = :user_id + WHERE id = :categoryId`, + { + "name": req.body.name, + "name_ru": req.body.name_ru, + "slug": req.body.slug, + "hidden": req.body.hidden, + "user_id": req.body.user_id, + "categoryId": req.params.categoryId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) + + app.delete('/v1/categories/:categoryId', (req, res, next) => { + pool.query( + `DELETE + FROM categories + WHERE id = :categoryId`, + { + "categoryId": req.params.categoryId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) + +} + +exports.register = register diff --git a/examples/js/routes.js b/examples/js/routes.js deleted file mode 100644 index 6d72fdc..0000000 --- a/examples/js/routes.js +++ /dev/null @@ -1,108 +0,0 @@ -const register = (app, pool) => { - - -app.get('/v1/categories/count', (req, res) => { - pool.query( - 'SELECT COUNT(*) AS counter FROM categories', - (err, rows, fields) => { - if (err) { - throw err - } - if (rows.length === 0) { - res.type('application/json').status(404).end() - return - } - res.json(rows[0]) - } - ) -}) - -app.get('/v1/collections/:collectionId/categories/count', (req, res) => { - pool.query( - 'SELECT COUNT(DISTINCT s.category_id) AS counter FROM collections_series cs JOIN series s ON s.id = cs.series_id WHERE cs.collection_id = :collectionId', - { "collectionId": req.params.collectionId }, - (err, rows, fields) => { - if (err) { - throw err - } - if (rows.length === 0) { - res.type('application/json').status(404).end() - return - } - res.json(rows[0]) - } - ) -}) - -app.get('/v1/categories', (req, res) => { - pool.query( - 'SELECT id , name , name_ru , slug FROM categories', - (err, rows, fields) => { - if (err) { - throw err - } - res.json(rows) - } - ) -}) - -app.post('/v1/categories', (req, res) => { - pool.query( - 'INSERT INTO categories ( name , name_ru , slug , created_at , created_by , updated_at , updated_by ) VALUES ( :name , :nameRu , :slug , NOW() , :userId , NOW() , :userId )', - { "name": req.body.name, "nameRu": req.body.nameRu, "slug": req.body.slug, "userId": req.body.userId }, - (err, rows, fields) => { - if (err) { - throw err - } - res.sendStatus(204) - } - ) -}) - -app.get('/v1/categories/:categoryId', (req, res) => { - pool.query( - 'SELECT id , name , name_ru , slug FROM categories WHERE id = :categoryId', - { "categoryId": req.params.categoryId }, - (err, rows, fields) => { - if (err) { - throw err - } - if (rows.length === 0) { - res.type('application/json').status(404).end() - return - } - res.json(rows[0]) - } - ) -}) - -app.put('/v1/categories/:categoryId', (req, res) => { - pool.query( - 'UPDATE categories SET name = :name , name_ru = :nameRu , slug = :slug , updated_at = NOW() , updated_by = :userId WHERE id = :categoryId', - { "name": req.body.name, "nameRu": req.body.nameRu, "slug": req.body.slug, "userId": req.body.userId, "categoryId": req.params.categoryId }, - (err, rows, fields) => { - if (err) { - throw err - } - res.sendStatus(204) - } - ) -}) - -app.delete('/v1/categories/:categoryId', (req, res) => { - pool.query( - 'DELETE FROM categories WHERE id = :categoryId', - { "categoryId": req.params.categoryId }, - (err, rows, fields) => { - if (err) { - throw err - } - res.sendStatus(204) - } - ) -}) - - -} - -exports.register = register; diff --git a/examples/python/fastapi/postgres/Dockerfile b/examples/python/fastapi/postgres/Dockerfile new file mode 100644 index 0000000..4a65ff4 --- /dev/null +++ b/examples/python/fastapi/postgres/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.7-bookworm +WORKDIR /opt/app +COPY requirements.txt ./ +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY *.py ./ +CMD [ "sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-3000}" ] diff --git a/examples/python/fastapi/postgres/app.py b/examples/python/fastapi/postgres/app.py new file mode 100644 index 0000000..8c04ac2 --- /dev/null +++ b/examples/python/fastapi/postgres/app.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from routes import router + +from custom_routes import router as custom_router + +app = FastAPI() + +@app.exception_handler(Exception) +async def exception_handler(request: Request, ex: Exception): + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"error": "Internal Server Error"} + ) + +app.include_router(router) + +app.include_router(custom_router) diff --git a/examples/python/fastapi/postgres/custom_routes.py b/examples/python/fastapi/postgres/custom_routes.py new file mode 100644 index 0000000..217571c --- /dev/null +++ b/examples/python/fastapi/postgres/custom_routes.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get('/custom/route') +def customRoute(): + return { "custom": True } + +@router.get('/custom/exception') +def customException(): + raise RuntimeError('expected error') diff --git a/examples/python/fastapi/postgres/db.py b/examples/python/fastapi/postgres/db.py new file mode 100644 index 0000000..be8065b --- /dev/null +++ b/examples/python/fastapi/postgres/db.py @@ -0,0 +1,11 @@ +import os +import psycopg2 + + +async def db_connection(): + return psycopg2.connect( + database=os.getenv('DB_NAME'), + user=os.getenv('DB_USER'), + password=os.getenv('DB_PASSWORD'), + host=os.getenv('DB_HOST', 'localhost'), + port=5432) diff --git a/examples/python/fastapi/postgres/endpoints.yaml b/examples/python/fastapi/postgres/endpoints.yaml new file mode 120000 index 0000000..9e6d040 --- /dev/null +++ b/examples/python/fastapi/postgres/endpoints.yaml @@ -0,0 +1 @@ +../../../js/express/mysql/endpoints.yaml \ No newline at end of file diff --git a/examples/python/fastapi/postgres/requirements.txt b/examples/python/fastapi/postgres/requirements.txt new file mode 100644 index 0000000..271ccad --- /dev/null +++ b/examples/python/fastapi/postgres/requirements.txt @@ -0,0 +1,3 @@ +fastapi===0.83.0; python_version >= "3.6" +uvicorn==0.18.3 +psycopg2-binary==2.9.3 diff --git a/examples/python/fastapi/postgres/routes.py b/examples/python/fastapi/postgres/routes.py new file mode 100644 index 0000000..7499161 --- /dev/null +++ b/examples/python/fastapi/postgres/routes.py @@ -0,0 +1,224 @@ +import psycopg2 +import psycopg2.extras + +from fastapi import APIRouter, Depends, HTTPException, status + +from pydantic import BaseModel + +from typing import Optional + +from db import db_connection + +router = APIRouter() + +class CreateCategoryDto(BaseModel): + name: Optional[str] = None + name_ru: Optional[str] = None + slug: Optional[str] = None + hidden: Optional[bool] = None + user_id: Optional[int] = None + + +@router.get('/v1/categories/count') +def get_v1_categories_count(conn=Depends(db_connection)): + try: + with conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SELECT COUNT(*) AS counter FROM categories") + result = cur.fetchone() + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return result + finally: + conn.close() + + +@router.get('/v1/categories/stat') +def get_v1_categories_stat(conn=Depends(db_connection)): + try: + with conn: + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + result = {} + cur.execute("SELECT COUNT(*) FROM categories") + result['total'] = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM categories WHERE name_ru IS NOT NULL") + result['in_russian'] = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM categories WHERE name IS NOT NULL") + result['in_english'] = cur.fetchone()[0] + cur.execute("SELECT COUNT(*) FROM categories WHERE name IS NOT NULL AND name_ru IS NOT NULL") + result['fully_translated'] = cur.fetchone()[0] + return result + finally: + conn.close() + + +@router.get('/v1/collections/{collectionId}/categories/count') +def get_v1_collections_collection_id_categories_count(collectionId, conn=Depends(db_connection)): + try: + with conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT COUNT(DISTINCT s.category_id) AS counter + FROM collections_series cs + JOIN series s + ON s.id = cs.series_id + WHERE cs.collection_id = %(collectionId)s + """, { + "collectionId": collectionId + }) + result = cur.fetchone() + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return result + finally: + conn.close() + + +@router.get('/v1/categories') +def get_list_v1_categories(conn=Depends(db_connection)): + try: + with conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + """) + return cur.fetchall() + finally: + conn.close() + + +@router.post('/v1/categories', status_code=status.HTTP_204_NO_CONTENT) +def post_v1_categories(body: CreateCategoryDto, conn=Depends(db_connection)): + try: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT + INTO categories + ( name + , name_ru + , slug + , hidden + , created_at + , created_by + , updated_at + , updated_by + ) + VALUES + ( %(name)s + , %(name_ru)s + , %(slug)s + , %(hidden)s + , CURRENT_TIMESTAMP + , %(user_id)s + , CURRENT_TIMESTAMP + , %(user_id)s + ) + """, { + "name": body.name, + "name_ru": body.name_ru, + "slug": body.slug, + "hidden": body.hidden, + "user_id": body.user_id + }) + finally: + conn.close() + + +@router.get('/v1/categories/search') +def get_list_v1_categories_search(hidden: bool, conn=Depends(db_connection)): + try: + with conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE hidden = %(hidden)s + """, { + "hidden": hidden + }) + return cur.fetchall() + finally: + conn.close() + + +@router.get('/v1/categories/{categoryId}') +def get_v1_categories_category_id(categoryId, conn=Depends(db_connection)): + try: + with conn: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE id = %(categoryId)s + """, { + "categoryId": categoryId + }) + result = cur.fetchone() + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return result + finally: + conn.close() + + +@router.put('/v1/categories/{categoryId}', status_code=status.HTTP_204_NO_CONTENT) +def put_v1_categories_category_id(body: CreateCategoryDto, categoryId, conn=Depends(db_connection)): + try: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE categories + SET name = %(name)s + , name_ru = %(name_ru)s + , slug = %(slug)s + , hidden = %(hidden)s + , updated_at = CURRENT_TIMESTAMP + , updated_by = %(user_id)s + WHERE id = %(categoryId)s + """, { + "name": body.name, + "name_ru": body.name_ru, + "slug": body.slug, + "hidden": body.hidden, + "user_id": body.user_id, + "categoryId": categoryId + }) + finally: + conn.close() + + +@router.delete('/v1/categories/{categoryId}', status_code=status.HTTP_204_NO_CONTENT) +def delete_v1_categories_category_id(categoryId, conn=Depends(db_connection)): + try: + with conn: + with conn.cursor() as cur: + cur.execute( + """ + DELETE + FROM categories + WHERE id = %(categoryId)s + """, { + "categoryId": categoryId + }) + finally: + conn.close() diff --git a/examples/ts/express/mysql/Dockerfile b/examples/ts/express/mysql/Dockerfile new file mode 100644 index 0000000..82b761d --- /dev/null +++ b/examples/ts/express/mysql/Dockerfile @@ -0,0 +1,9 @@ +FROM node:18-bookworm +WORKDIR /opt/app +COPY package.json ./ +ENV NPM_CONFIG_UPDATE_NOTIFIER=false +RUN npm install --no-audit --no-fund +COPY tsconfig.json *.ts ./ +RUN npm run build +USER node +CMD [ "npm", "start" ] diff --git a/examples/ts/express/mysql/app.ts b/examples/ts/express/mysql/app.ts new file mode 100644 index 0000000..d97a004 --- /dev/null +++ b/examples/ts/express/mysql/app.ts @@ -0,0 +1,49 @@ +import express from 'express' +import { NextFunction, Request, Response } from 'express' +import mysql from 'mysql' + +const routes = require('./routes') +const custom_routes = require('./custom_routes') + +const app = express() +app.use(express.json()) + +const pool = mysql.createPool({ + connectionLimit: 2, + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + // Support of named placeholders (https://github.com/mysqljs/mysql#custom-format) + queryFormat: function(this: mysql.Pool, query, values) { + if (!values) { + return query + } + return query.replace(/\:(\w+)/g, function(this: mysql.Pool, matchedSubstring: string, capturedValue: string) { + if (values.hasOwnProperty(capturedValue)) { + return this.escape(values[capturedValue]) + } + return matchedSubstring + }.bind(this)) + }, + // Support for conversion from TINYINT(1) to boolean (https://github.com/mysqljs/mysql#custom-type-casting) + typeCast: function(field, next) { + if (field.type === 'TINY' && field.length === 1) { + return field.string() === '1' + } + return next() + } +}) + +routes.register(app, pool) +custom_routes.register(app, pool) + +app.use((error: any, req: Request, res: Response, next: NextFunction) => { + console.error(error) + res.status(500).json({ "error": "Internal Server Error" }) +}) + +const port = process.env.PORT || 3000 +app.listen(port, () => { + console.log(`Listen on ${port}`) +}) diff --git a/examples/ts/express/mysql/custom_routes.ts b/examples/ts/express/mysql/custom_routes.ts new file mode 100644 index 0000000..0f4dbff --- /dev/null +++ b/examples/ts/express/mysql/custom_routes.ts @@ -0,0 +1,14 @@ +import { Express, NextFunction, Request, Response } from 'express' +import { Pool } from 'mysql' + +exports.register = (app: Express, pool: Pool) => { + + app.get('/custom/route', (req: Request, res: Response, next: NextFunction) => { + res.json({ "custom": true }) + }) + + app.get('/custom/exception', (req: Request, res: Response, next: NextFunction) => { + throw new Error('expected err') + }) + +} diff --git a/examples/ts/express/mysql/endpoints.yaml b/examples/ts/express/mysql/endpoints.yaml new file mode 120000 index 0000000..9e6d040 --- /dev/null +++ b/examples/ts/express/mysql/endpoints.yaml @@ -0,0 +1 @@ +../../../js/express/mysql/endpoints.yaml \ No newline at end of file diff --git a/examples/ts/express/mysql/package.json b/examples/ts/express/mysql/package.json new file mode 100644 index 0000000..e75c798 --- /dev/null +++ b/examples/ts/express/mysql/package.json @@ -0,0 +1,17 @@ +{ + "name": "mysql", + "version": "1.0.0", + "scripts": { + "build": "npx tsc", + "start": "node dist/app.js" + }, + "dependencies": { + "express": "~4.17.1", + "mysql": "~2.18.1" + }, + "devDependencies": { + "@types/express": "~4.17.17", + "@types/mysql": "~2.15.21", + "typescript": "~5.1.6" + } +} diff --git a/examples/ts/express/mysql/routes.ts b/examples/ts/express/mysql/routes.ts new file mode 100644 index 0000000..57a28a1 --- /dev/null +++ b/examples/ts/express/mysql/routes.ts @@ -0,0 +1,197 @@ +import { Express, NextFunction, Request, Response } from 'express' +import { Pool } from 'mysql' + +const parseBoolean = (value: any) => { + return typeof value === 'string' && value === 'true' +} + +const register = (app: Express, pool: Pool) => { + + app.get('/v1/categories/count', (req: Request, res: Response, next: NextFunction) => { + pool.query( + "SELECT COUNT(*) AS counter FROM categories", + (err, rows, fields) => { + if (err) { + return next(err) + } + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) + } + ) + }) + + app.get('/v1/collections/:collectionId/categories/count', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `SELECT COUNT(DISTINCT s.category_id) AS counter + FROM collections_series cs + JOIN series s + ON s.id = cs.series_id + WHERE cs.collection_id = :collectionId`, + { + "collectionId": req.params.collectionId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) + } + ) + }) + + app.get('/v1/categories', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories`, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.json(rows) + } + ) + }) + + app.post('/v1/categories', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `INSERT + INTO categories + ( name + , name_ru + , slug + , hidden + , created_at + , created_by + , updated_at + , updated_by + ) + VALUES + ( :name + , :name_ru + , :slug + , :hidden + , CURRENT_TIMESTAMP + , :user_id + , CURRENT_TIMESTAMP + , :user_id + )`, + { + "name": req.body.name, + "name_ru": req.body.name_ru, + "slug": req.body.slug, + "hidden": req.body.hidden, + "user_id": req.body.user_id + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) + + app.get('/v1/categories/search', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE hidden = :hidden`, + { + "hidden": parseBoolean(req.query.hidden) + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.json(rows) + } + ) + }) + + app.get('/v1/categories/:categoryId', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `SELECT id + , name + , name_ru + , slug + , hidden + FROM categories + WHERE id = :categoryId`, + { + "categoryId": req.params.categoryId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) + } + ) + }) + + app.put('/v1/categories/:categoryId', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `UPDATE categories + SET name = :name + , name_ru = :name_ru + , slug = :slug + , hidden = :hidden + , updated_at = CURRENT_TIMESTAMP + , updated_by = :user_id + WHERE id = :categoryId`, + { + "name": req.body.name, + "name_ru": req.body.name_ru, + "slug": req.body.slug, + "hidden": req.body.hidden, + "user_id": req.body.user_id, + "categoryId": req.params.categoryId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) + + app.delete('/v1/categories/:categoryId', (req: Request, res: Response, next: NextFunction) => { + pool.query( + `DELETE + FROM categories + WHERE id = :categoryId`, + { + "categoryId": req.params.categoryId + }, + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) + +} + +exports.register = register diff --git a/examples/ts/express/mysql/tsconfig.json b/examples/ts/express/mysql/tsconfig.json new file mode 100644 index 0000000..904a393 --- /dev/null +++ b/examples/ts/express/mysql/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..560d20e --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +go = "1.14.15" +node = "18.12.0" +python = "3.7.17" diff --git a/package-lock.json b/package-lock.json index aba5a2f..e231f22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,252 @@ { "name": "query2app", - "version": "0.0.2", - "lockfileVersion": 1, + "version": "0.0.3", + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "name": "query2app", + "version": "0.0.3", + "license": "GPL-2.0", + "dependencies": { + "ejs": "~3.1.10", + "js-yaml": "~3.14.0", + "minimist": "~1.2.8", + "node-sql-parser": "~3.0.4" + }, + "bin": { + "query2app": "src/cli.js" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/big-integer": { + "version": "1.6.48", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz", + "integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-sql-parser": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-3.0.4.tgz", + "integrity": "sha512-ANB/paC3ZdcvrbRdiuQFsTvrmgzPozDUueTivO0Iep82h6KVRoER2h/KeZPsG0umYtCzwGY6KNhvB8xtJ6B/Rw==", + "dependencies": { + "big-integer": "^1.6.48" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + } + }, "dependencies": { "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "requires": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" } }, "argparse": { @@ -21,14 +258,19 @@ } }, "async": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", - "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=" + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, "balanced-match": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "big-integer": { + "version": "1.6.48", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz", + "integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==" }, "brace-expansion": { "version": "1.1.11", @@ -40,73 +282,85 @@ } }, "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" } }, "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "requires": { - "color-name": "1.1.3" + "color-name": "~1.1.4" } }, "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "ejs": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.3.tgz", - "integrity": "sha512-wmtrUGyfSC23GC/B1SMv2ogAUgbQEtDmTIhfqielrG5ExIM9TP4UoYdi90jLF1aTcsWCJNEO0UrgKzP0y3nTSg==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "requires": { - "jake": "^10.6.1" + "jake": "^10.8.5" } }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" - }, "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "filelist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.1.tgz", - "integrity": "sha512-8zSK6Nu0DQIC08mUC46sWGXi+q3GGpKydAG36k+JDba6VRpkevvOWUW5a/PhShij4+vHT9M+ghgG7eM+a9JDUQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", "requires": { - "minimatch": "^3.0.4" + "minimatch": "^5.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + } } }, "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "jake": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz", - "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==", + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", "requires": { - "async": "0.9.x", - "chalk": "^2.4.2", - "filelist": "^1.0.1", - "minimatch": "^3.0.4" + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" } }, "js-yaml": { @@ -119,24 +373,37 @@ } }, "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "requires": { "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "node-sql-parser": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-3.0.4.tgz", + "integrity": "sha512-ANB/paC3ZdcvrbRdiuQFsTvrmgzPozDUueTivO0Iep82h6KVRoER2h/KeZPsG0umYtCzwGY6KNhvB8xtJ6B/Rw==", + "requires": { + "big-integer": "^1.6.48" + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "requires": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" } } } diff --git a/package.json b/package.json index dd5779a..6382885 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "query2app", - "version": "0.0.2", + "version": "0.0.3", "description": "Generates the endpoints from SQL -> URL mapping", "keywords": [ "sql", @@ -22,12 +22,20 @@ "bin": { "query2app": "./src/cli.js" }, + "files": [ + "src/**" + ], "scripts": { - "start": "node src/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "example:all": "npm run example:js && npm run example:ts && npm run example:go && npm run example:py", + "example:js": "cd examples/js/express/mysql; ../../../../src/cli.js --lang js", + "example:ts": "cd examples/ts/express/mysql; ../../../../src/cli.js --lang ts", + "example:go": "cd examples/go/chi/mysql; ../../../../src/cli.js --lang go", + "example:py": "cd examples/python/fastapi/postgres; ../../../../src/cli.js --lang python" }, "dependencies": { - "ejs": "~3.1.3", - "js-yaml": "~3.14.0" + "ejs": "~3.1.10", + "js-yaml": "~3.14.0", + "minimist": "~1.2.8", + "node-sql-parser": "~3.0.4" } } diff --git a/src/cli.js b/src/cli.js index 499715e..d445c92 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,132 +1,456 @@ #!/usr/bin/env node -const yaml = require('js-yaml'); -const ejs = require('ejs'); -const fs = require('fs'); -const path = require('path'); +const yaml = require('js-yaml') +const ejs = require('ejs') +const fs = require('fs') +const fsPromises = require('fs/promises') +const path = require('path') -const endpointsFile = 'endpoints.yaml'; -const appFile = 'app.js'; -const routesFile = 'routes.js'; +const parseArgs = require('minimist') + +const { Parser } = require('node-sql-parser') + +const Generator = require('./generator/Generator') + +const endpointsFile = 'endpoints.yaml' + +const parseCommandLineArgs = (args) => { + const opts = { + // @todo #24 Document --dest-dir option + 'string': [ 'lang', 'dest-dir' ], + 'default': { + 'lang': 'js', + 'dest-dir': '.' + } + } + const argv = parseArgs(args, opts) + //console.debug('argv:', argv) + return argv +} + +// Restructure YAML configuration to simplify downstream code. +// +// Converts +// { +// get_list: { query: }, +// put: { query: } +// } +// into +// { +// methods: [ +// { name: get_list, verb: get, query: }, +// { name: put, verb: put, query: } +// ] +// } +const restructureConfiguration = (config) => { + for (const endpoint of config) { + endpoint.methods = []; // this semicolon is really needed + [ 'get', 'get_list', 'post', 'put', 'delete' ].forEach(method => { + if (!endpoint.hasOwnProperty(method)) { + return + } + endpoint.methods.push({ + 'name': method, + 'verb': method !== 'get_list' ? method : 'get', + ...endpoint[method], + }) + delete endpoint[method] + }) + } +} const loadConfig = (endpointsFile) => { - console.log('Read', endpointsFile); + console.log('Read', endpointsFile) try { - const content = fs.readFileSync(endpointsFile, 'utf8'); - const config = yaml.safeLoad(content); - //console.debug(config); - return config; + const content = fs.readFileSync(endpointsFile, 'utf8') + const config = yaml.safeLoad(content) + restructureConfiguration(config) + //console.debug(config) + return config } catch (ex) { - console.error(`Failed to parse ${endpointsFile}: ${ex.message}`); - throw ex; + console.error(`Failed to parse ${endpointsFile}: ${ex.message}`) + throw ex + } +} + +const lang2extension = (lang) => { + switch (lang) { + case 'js': + case 'ts': + case 'go': + return lang + case 'python': + return 'py' + default: + throw new Error(`Unsupported language: ${lang}`) + } +} + +const findFileNamesEndWith = (dir, postfix) => { + return fs.readdirSync(dir).filter(name => name.endsWith(postfix)) +} + +const createApp = async (destDir, { lang }) => { + const ext = lang2extension(lang) + const fileName = `app.${ext}` + console.log('Generate', fileName) + const resultFile = path.join(destDir, fileName) + const customRouters = findFileNamesEndWith(destDir, `_routes.${ext}`) + if (customRouters.length > 0) { + customRouters.forEach(filename => console.log(`Include a custom router from ${filename}`)) + } + + const resultedCode = await ejs.renderFile( + `${__dirname}/templates/${fileName}.ejs`, + { + // @todo #27 Document usage of user defined routes + 'customRouteFilenames': customRouters, + 'capitalize': capitalize, + } + ) + + return fsPromises.writeFile(resultFile, resultedCode) +} + +const createDb = async (destDir, { lang }) => { + if (lang !== 'python') { + return } -}; + const fileName = 'db.py' + console.log('Generate', fileName) + const resultFile = path.join(destDir, fileName) -const createApp = async (destDir, fileName) => { - console.log('Generate', fileName); - const resultFile = path.join(destDir, fileName); + const resultedCode = await ejs.renderFile( + `${__dirname}/templates/${fileName}.ejs` + ) + + return fsPromises.writeFile(resultFile, resultedCode) +} - fs.copyFileSync(__dirname + '/templates/app.js', resultFile) -}; +// "-- comment\nSELECT * FROM foo" => "SELECT * FROM foo" +const removeComments = (query) => query.replace(/--.*\n/g, '') // "SELECT *\n FROM foo" => "SELECT * FROM foo" -const flattenQuery = (query) => query.replace(/\n[ ]*/g, ' '); +const flattenQuery = (query) => query.replace(/\n[ ]*/g, ' ') + +// "WHERE id = :p.categoryId OR id = :b.id LIMIT :q.limit" => "WHERE id = :categoryId OR id = :id LIMIT :limit" +const removePlaceholders = (query) => query.replace(/(?<=:)[pbq]\./g, '') + +// "/categories/:id" => "/categories/{id}" +// (used only with Golang's go-chi) +const convertPathPlaceholders = (path) => path.replace(/:([^\/]+)/g, '{$1}') -// "WHERE id = :p.categoryId OR id = :b.id" => "WHERE id = :categoryId OR id = :id" -const removePlaceholders = (query) => query.replace(/:[pb]\./g, ':'); +// "name_ru" => "nameRu" +// (used only with Golang's go-chi) +const snake2camelCase = (str) => str.replace(/_([a-z])/g, (match, group) => group.toUpperCase()) -const createEndpoints = async (destDir, fileName, config) => { - console.log('Generate', fileName); - const resultFile = path.join(destDir, fileName); +// "categoryId" => "category_id" +// (used only with Python's FastAPI) +const camel2snakeCase = (str) => str.replace(/([A-Z])/g, (match, group) => '_' + group.toLowerCase()) + +// "nameRu" => "NameRu" +// (used only with Golang's go-chi) +const capitalize = (str) => str[0].toUpperCase() + str.slice(1) + +// ["a", "bb", "ccc"] => 3 +// (used only with Golang's go-chi) +const lengthOfLongestString = (arr) => arr + .map(el => el.length) + .reduce( + (acc, val) => val > acc ? val : acc, + 0 /* initial value */ + ) + +// returns user-defined variable's type or null +// Accepts method.dto.fields or method.params as fieldsInfo +const retrieveType = (fieldsInfo, fieldName) => { + const hasTypeInfo = fieldsInfo.hasOwnProperty(fieldName) && fieldsInfo[fieldName].hasOwnProperty('type') + if (hasTypeInfo) { + return fieldsInfo[fieldName].type + } + return null +} + +const createEndpoints = async (destDir, { lang }, config) => { + const ext = lang2extension(lang) + const fileName = `routes.${ext}` + console.log('Generate', fileName) + const resultFile = path.join(destDir, fileName) for (let endpoint of config) { - if (endpoint.hasOwnProperty('get')) { - console.log('GET', endpoint.path, '=>', removePlaceholders(flattenQuery(endpoint.get))); - } else if (endpoint.hasOwnProperty('get_list')) { - console.log('GET', endpoint.path, '=>', removePlaceholders(flattenQuery(endpoint.get_list))); - } - if (endpoint.hasOwnProperty('post')) { - console.log('POST', endpoint.path, '=>', removePlaceholders(flattenQuery(endpoint.post))); - } - if (endpoint.hasOwnProperty('put')) { - console.log('PUT', endpoint.path, '=>', removePlaceholders(flattenQuery(endpoint.put))); - } - if (endpoint.hasOwnProperty('delete')) { - console.log('DELETE', endpoint.path, '=>', removePlaceholders(flattenQuery(endpoint.delete))); + let path = endpoint.path + if (lang === 'go') { + path = convertPathPlaceholders(path) } + endpoint.methods.forEach(method => { + const verb = method.verb.toUpperCase() + console.log(`\t${verb} ${path}`) + + let queries = [] + if (method.query) { + queries.push(method.query) + } else if (method.aggregated_queries) { + queries = Object.values(method.aggregated_queries) + } + }) } const placeholdersMap = { - 'p': 'req.params', - 'b': 'req.body' + 'js': { + 'p': 'req.params', + 'b': 'req.body', + 'q': 'req.query', + }, + 'go': { + 'p': function(param) { + return `chi.URLParam(r, "${param}")` + }, + 'b': function(param) { + return 'body.' + capitalize(snake2camelCase(param)) + }, + 'q': function(param) { + return `r.URL.Query().Get("${param}")` + }, + }, + 'py': { + 'p': '', + 'b': 'body.', + 'q': '', + }, } + const parser = new Parser() + const resultedCode = await ejs.renderFile( - __dirname + '/templates/routes.js.ejs', + `${__dirname}/templates/routes.${ext}.ejs`, { "endpoints": config, - // "... WHERE id = :p.id" => [ "p.id" ] => [ "p.id" ] - "extractParams": (query) => { - const params = query.match(/:[pb]\.\w+/g) || []; - return params.length > 0 - ? params.map(p => p.substring(1)) - : params; + // "... WHERE id = :p.id" => [ "p.id" ] + "extractParamsFromQuery": (query) => query.match(/(?<=:)[pbq]\.\w+/g) || [], + + // "p.id" => "id" + the same for "q" and "b" + // (used only with FastAPI) + "stipOurPrefixes": (str) => str.replace(/^[pbq]\./, ''), + + // "/categories/:categoryId" => [ "categoryId" ] + // (used only with FastAPI) + "extractParamsFromPath": (query) => query.match(/(?<=:)\w+/g) || [], + + // [ "p.page", "b.num" ] => '"page": req.params.page, "num": req.body.num' + // (used only with Express) + "formatParamsAsJavaScriptObject": (params, method) => { + if (params.length === 0) { + return params + } + const initialIndentLevel = 12 + const codeIndentLevel = initialIndentLevel + 4 + const initialIndent = ' '.repeat(initialIndentLevel) + const indent = ' '.repeat(codeIndentLevel) + return `\n${initialIndent}{\n` + Array.from( + new Set(params), + p => { + const bindTarget = p.substring(0, 1) + const paramName = p.substring(2) + const prefix = placeholdersMap['js'][bindTarget] + // LATER: add support for path (method.params.path) and body (method.dto.fields) parameters + if (bindTarget === 'q' && retrieveType(method.params.query, paramName) === 'boolean') { + return `${indent}"${paramName}": parseBoolean(${prefix}.${paramName})` + } + return `${indent}"${paramName}": ${prefix}.${paramName}` + } + ).join(',\n') + `\n${initialIndent}},` }, - // [ "p.page", "b.num" ] => '{ "page" : req.params.page, "num": req.body.num }' - "formatParams": (params) => { - return params.length > 0 - ? '{ ' + Array.from(new Set(params), p => `"${p.substring(2)}": ${placeholdersMap[p.substring(0, 1)]}.${p.substring(2)}`).join(', ') + ' }' - : params; + // "SELECT *\n FROM foo WHERE id = :p.id" => "SELECT * FROM foo WHERE id = :id" + "formatQueryAsSingleLine": (query) => { + return removePlaceholders(flattenQuery(removeComments(query))) }, - // "SELECT *\n FROM foo" => "'SELECT * FROM foo'" - "formatQuery": (query) => { - return "'" + removePlaceholders(flattenQuery(query)) + "'"; - } + // Uses backticks for multiline strings. + // (used only with JS, TS, Golang) + "formatQueryForJs": (query, indentLevel) => { + const sql = removePlaceholders(removeComments(query)) + const indent = ' '.repeat(indentLevel) + const isMultilineSql = sql.indexOf('\n') >= 0 + if (isMultilineSql) { + const indentedSql = sql.replace(/\n/g, '\n' + indent) + return "\n" + indent + '`' + indentedSql + '`' + } + return `\n${indent}"${sql}"` + }, + + // Uses """ for multiline strings. + // (used only with Python) + "formatQueryForPython": (query, indentLevel) => { + const sql = removePlaceholders(removeComments(query)) + const isMultilineSql = sql.indexOf('\n') >= 0 + if (isMultilineSql) { + const indent = ' '.repeat(indentLevel) + const indentedSql = sql.replace(/\n/g, '\n' + indent) + return `\n${indent}"""\n${indent}${indentedSql}\n${indent}"""` + } + return `"${sql}"` + }, + + // (used only with Golang) + "convertPathPlaceholders": convertPathPlaceholders, + "sqlParser": parser, + "removePlaceholders": removePlaceholders, + "snake2camelCase": snake2camelCase, + "capitalize": capitalize, + "lengthOfLongestString": lengthOfLongestString, + + // used only with Pyth + "camel2snakeCase": camel2snakeCase, + + // [ "p.page", "b.num" ] => '"page": chi.URLParam(r, "page"),\n\t\t\t"num": dto.Num),' + // (used only with Golang's go-chi) + "formatParamsAsGolangMap": (params, method) => { + if (params.length === 0) { + return params + } + const maxParamNameLength = lengthOfLongestString(params) + return Array.from( + new Set(params), + p => { + const bindTarget = p.substring(0, 1) + const paramName = p.substring(2) + const formatFunc = placeholdersMap['go'][bindTarget] + const quotedParam = '"' + paramName + '":' + let extractParamExpr = formatFunc(paramName) + // LATER: add support for path (method.params.path) and body (method.dto.fields) parameters + if (bindTarget === 'q' && retrieveType(method.params.query, paramName) === 'boolean') { + extractParamExpr = `parseBoolean(${extractParamExpr})` + } + // We don't count quotes and colon because they are compensated by "p." prefix. + // We do +1 because the longest parameter will also have an extra space as a delimiter. + return `${quotedParam.padEnd(maxParamNameLength+1)} ${extractParamExpr},` + } + ).join('\n\t\t\t') + }, + + // [ "p.categoryId" ] => ', {"categoryId": body.categoryId}' + // (used only with Python) + "formatParamsAsPythonDict": (params) => { + if (params.length === 0) { + return params + } + const indentLevel = 24 + const indent = ' '.repeat(indentLevel) + const closingIndent = ' '.repeat(indentLevel - 4) + return ', {\n' + Array.from( + new Set(params), + p => { + const bindTarget = p.substring(0, 1) + const paramName = p.substring(2) + const prefix = placeholdersMap['py'][bindTarget] + return `${indent}"${paramName}": ${prefix}${paramName}` + } + ).join(',\n') + `\n${closingIndent}}` + }, + + "placeholdersMap": placeholdersMap, + "removeComments": removeComments, + "retrieveType": retrieveType, } - ); + ) + + return fsPromises.writeFile(resultFile, resultedCode) +} + +const createDependenciesDescriptor = async (destDir, { lang }) => { + let fileName + if (lang === 'js' || lang === 'ts') { + fileName = 'package.json' + + } else if (lang === 'go') { + fileName = 'go.mod' - fs.writeFileSync(resultFile, resultedCode); -}; + } else if (lang === 'python') { + fileName = 'requirements.txt' -const createPackageJson = async (destDir, fileName) => { - console.log('Generate', fileName); + } else { + return + } + + console.log('Generate', fileName) - const resultFile = path.join(destDir, fileName); - const projectName = path.basename(destDir); - console.log('Project name:', projectName); + const resultFile = path.join(destDir, fileName) + // @todo #24 [js] Possibly incorrect project name with --dest-dir option + const projectName = path.basename(destDir) + if (lang === 'js' || lang === 'ts') { + console.log('Project name:', projectName) + } const minimalPackageJson = await ejs.renderFile( - __dirname + '/templates/package.json.ejs', + `${__dirname}/templates/${fileName}.ejs`, { + lang, + // project name is being used only for package.json + // @todo #35 [js] Let a user to specify project name projectName } - ); + ) - fs.writeFileSync(resultFile, minimalPackageJson); -}; + return fsPromises.writeFile(resultFile, minimalPackageJson) +} -const config = loadConfig(endpointsFile); +const createDockerfile = async (destDir, lang) => { + const fileName = 'Dockerfile' + console.log('Generate', fileName) -let [,, destDir = '.'] = process.argv; -destDir = path.resolve(process.cwd(), destDir); -console.log('Destination directory:', destDir) + const resultFile = path.join(destDir, fileName) -if (!fs.existsSync(destDir)) { - console.log('Create', destDir) - fs.mkdirSync(destDir, {recursive: true}); + return fsPromises.copyFile(`${__dirname}/templates/${fileName}.${lang}`, resultFile) } -createApp(destDir, appFile, config); -createEndpoints(destDir, routesFile, config); -createPackageJson(destDir, 'package.json'); - -console.info(`The application has been generated! -Use - npm install -to install its dependencies and - export DB_NAME=db DB_USER=user DB_PASSWORD=secret - npm start -afteward to run it`); +const createTypeScriptConfig = async (destDir, lang) => { + if (lang !== 'ts') { + return + } + const fileName = 'tsconfig.json' + console.log('Generate', fileName) + + const resultFile = path.join(destDir, fileName) + + const tsConfigJson = await ejs.renderFile( + `${__dirname}/templates/${fileName}.ejs` + ) + + return fsPromises.writeFile(resultFile, tsConfigJson) +} + +const absolutePathToDestDir = (argv) => { + const relativeDestDir = argv._.length > 0 ? argv._[0] : argv['dest-dir'] + return path.resolve(process.cwd(), relativeDestDir) +} + +const main = async (argv) => { + const config = loadConfig(endpointsFile) + + const destDir = absolutePathToDestDir(argv) + console.log('Destination directory:', destDir) + + if (!fs.existsSync(destDir)) { + console.log('Create', destDir) + fs.mkdirSync(destDir, {recursive: true}) + } + + const lang = lang2extension(argv.lang) + const generator = Generator.for(lang) + + await createApp(destDir, argv) + await createDb(destDir, argv) + await createEndpoints(destDir, argv, config) + await createDependenciesDescriptor(destDir, argv) + await createTypeScriptConfig(destDir, argv.lang) + await createDockerfile(destDir, lang) + + console.info('The application has been generated!') + console.info(generator.usageExampleAsText()) +} + + +const argv = parseCommandLineArgs(process.argv.slice(2)) +main(argv) diff --git a/src/generator/Generator.js b/src/generator/Generator.js new file mode 100644 index 0000000..b889f57 --- /dev/null +++ b/src/generator/Generator.js @@ -0,0 +1,23 @@ +const JsGenerator = require('./JsGenerator') +const TsGenerator = require('./TsGenerator') +const GoGenerator = require('./GoGenerator') +const PyGenerator = require('./PyGenerator') + +module.exports = class Generator { + + static for(lang) { + switch (lang) { + case 'js': + return new JsGenerator() + case 'ts': + return new TsGenerator() + case 'go': + return new GoGenerator() + case 'py': + return new PyGenerator() + default: + throw new Error(`Unsupported language: ${lang}`) + } + } + +} diff --git a/src/generator/GoGenerator.js b/src/generator/GoGenerator.js new file mode 100644 index 0000000..1d34590 --- /dev/null +++ b/src/generator/GoGenerator.js @@ -0,0 +1,14 @@ +module.exports = class GoGenerator { + + usageExampleAsText() { + return `Use + export DB_NAME=db DB_USER=user DB_PASSWORD=secret + go run *.go +or + go build -o app + export DB_NAME=db DB_USER=user DB_PASSWORD=secret + ./app +to build and run it` + } + +} diff --git a/src/generator/JsGenerator.js b/src/generator/JsGenerator.js new file mode 100644 index 0000000..13f658c --- /dev/null +++ b/src/generator/JsGenerator.js @@ -0,0 +1,12 @@ +module.exports = class JsGenerator { + + usageExampleAsText() { + return `Use + npm install +to install its dependencies and + export DB_NAME=db DB_USER=user DB_PASSWORD=secret + npm start +afteward to run` + } + +} diff --git a/src/generator/PyGenerator.js b/src/generator/PyGenerator.js new file mode 100644 index 0000000..14f5b6f --- /dev/null +++ b/src/generator/PyGenerator.js @@ -0,0 +1,12 @@ +module.exports = class PyGenerator { + + usageExampleAsText() { + return `Use + pip install -r requirements.txt +to install its dependencies and + export DB_NAME=db DB_USER=user DB_PASSWORD=secret + uvicorn app:app --port 3000 +afteward to run` + } + +} diff --git a/src/generator/TsGenerator.js b/src/generator/TsGenerator.js new file mode 100644 index 0000000..0fa46cc --- /dev/null +++ b/src/generator/TsGenerator.js @@ -0,0 +1,14 @@ +module.exports = class TsGenerator { + + usageExampleAsText() { + return `Use + npm install +to install its dependencies, + npm run build +to build the application, and + export DB_NAME=db DB_USER=user DB_PASSWORD=secret + npm start +afteward to run` + } + +} diff --git a/src/templates/Dockerfile.go b/src/templates/Dockerfile.go new file mode 100644 index 0000000..5d60014 --- /dev/null +++ b/src/templates/Dockerfile.go @@ -0,0 +1,11 @@ +FROM golang:1.14 AS builder +WORKDIR /opt +COPY go.mod ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app + +FROM scratch +WORKDIR /opt/app +COPY --from=builder /opt/app . +CMD [ "/opt/app/app" ] diff --git a/src/templates/Dockerfile.js b/src/templates/Dockerfile.js new file mode 100644 index 0000000..efc57fe --- /dev/null +++ b/src/templates/Dockerfile.js @@ -0,0 +1,8 @@ +FROM node:18-bookworm +WORKDIR /opt/app +COPY package.json ./ +ENV NPM_CONFIG_UPDATE_NOTIFIER=false +RUN npm install --no-audit --no-fund +COPY *.js ./ +USER node +CMD [ "npm", "start" ] diff --git a/src/templates/Dockerfile.py b/src/templates/Dockerfile.py new file mode 100644 index 0000000..4a65ff4 --- /dev/null +++ b/src/templates/Dockerfile.py @@ -0,0 +1,6 @@ +FROM python:3.7-bookworm +WORKDIR /opt/app +COPY requirements.txt ./ +RUN pip install --no-cache-dir --upgrade -r requirements.txt +COPY *.py ./ +CMD [ "sh", "-c", "exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-3000}" ] diff --git a/src/templates/Dockerfile.ts b/src/templates/Dockerfile.ts new file mode 100644 index 0000000..82b761d --- /dev/null +++ b/src/templates/Dockerfile.ts @@ -0,0 +1,9 @@ +FROM node:18-bookworm +WORKDIR /opt/app +COPY package.json ./ +ENV NPM_CONFIG_UPDATE_NOTIFIER=false +RUN npm install --no-audit --no-fund +COPY tsconfig.json *.ts ./ +RUN npm run build +USER node +CMD [ "npm", "start" ] diff --git a/src/templates/app.go.ejs b/src/templates/app.go.ejs new file mode 100644 index 0000000..21e447f --- /dev/null +++ b/src/templates/app.go.ejs @@ -0,0 +1,65 @@ +<% +// "custom_routes.go" => "registerCustomRoutes" +function fileName2registerRouterFunc(filename) { + const routerName = filename.replace(/_routes\.go$/, '') + return `register${capitalize(routerName)}Routes` +} +-%> +package main + +import "fmt" +import "net/http" +import "os" +import "github.com/go-chi/chi" +import "github.com/jmoiron/sqlx" + +import _ "github.com/go-sql-driver/mysql" + +func main() { + mapper := func(name string) string { + value := os.Getenv(name) + switch name { + case "DB_HOST": + if value == "" { + value = "localhost" + } + case "DB_NAME", "DB_USER", "DB_PASSWORD": + if value == "" { + fmt.Fprintf(os.Stderr, "%s env variable is not set or empty\n", name) + os.Exit(1) + } + } + return value + } + + dsn := os.Expand("${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/${DB_NAME}", mapper) + db, err := sqlx.Open("mysql", dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "sqlx.Open failed: %v\n", err) + os.Exit(1) + } + defer db.Close() + + if err = db.Ping(); err != nil { + fmt.Fprintf(os.Stderr, "Ping failed: could not connect to database: %v\n", err) + os.Exit(1) + } + + r := chi.NewRouter() + registerRoutes(r, db) +<% customRouteFilenames.forEach(filename => { + const registerRouterFunc = fileName2registerRouterFunc(filename) +-%> + <%- registerRouterFunc %>(r, db) +<% }) -%> + + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + fmt.Println("Listen on " + port) + err = http.ListenAndServe(":"+port, r) + fmt.Fprintf(os.Stderr, "ListenAndServe failed: %v\n", err) + os.Exit(1) +} diff --git a/src/templates/app.js b/src/templates/app.js deleted file mode 100644 index 4b92f0c..0000000 --- a/src/templates/app.js +++ /dev/null @@ -1,34 +0,0 @@ -const bodyParser = require('body-parser') -const express = require('express') -const mysql = require('mysql') -const routes = require('./routes') - -const app = express() -app.use(bodyParser.json()) - -const pool = mysql.createPool({ - connectionLimit: 2, - host: process.env.DB_HOST || 'localhost', - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - // Support of named placeholders (https://github.com/mysqljs/mysql#custom-format) - queryFormat: function(query, values) { - if (!values) { - return query; - } - return query.replace(/\:(\w+)/g, function(txt, key) { - if (values.hasOwnProperty(key)) { - return this.escape(values[key]); - } - return txt; - }.bind(this)); - } -}) - -routes.register(app, pool) - -const port = process.env.PORT || 3000; -app.listen(port, () => { - console.log(`Listen on ${port}`) -}) diff --git a/src/templates/app.js.ejs b/src/templates/app.js.ejs new file mode 100644 index 0000000..9379161 --- /dev/null +++ b/src/templates/app.js.ejs @@ -0,0 +1,63 @@ +<% +// "custom_routes.js" => "custom_routes" +function removeExtension(filename) { + return filename.replace(/\.js$/, '') +} +-%> +const express = require('express') +const mysql = require('mysql') +const routes = require('./routes') +<% customRouteFilenames.forEach(filename => { + const routerName = removeExtension(filename) +-%> +const <%- routerName %> = require('./<%- routerName %>') +<% }) -%> + +const app = express() +app.use(express.json()) + +const pool = mysql.createPool({ + connectionLimit: 2, + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + // Support of named placeholders (https://github.com/mysqljs/mysql#custom-format) + queryFormat: function(query, values) { + if (!values) { + return query + } +<%# See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_function_as_the_replacement -%> + return query.replace(/\:(\w+)/g, function(matchedSubstring, capturedValue) { + if (values.hasOwnProperty(capturedValue)) { + return this.escape(values[capturedValue]) + } + return matchedSubstring + }.bind(this)) + }, +<%# LATER: add it only when there is at least one DTO with boolean type -%> + // Support for conversion from TINYINT(1) to boolean (https://github.com/mysqljs/mysql#custom-type-casting) + typeCast: function(field, next) { + if (field.type === 'TINY' && field.length === 1) { + return field.string() === '1' + } + return next() + } +}) + +routes.register(app, pool) +<% customRouteFilenames.forEach(filename => { + const routerName = removeExtension(filename) +-%> +<%- routerName %>.register(app, pool) +<% }) -%> + +app.use((error, req, res, next) => { + console.error(error) + res.status(500).json({ "error": "Internal Server Error" }) +}) + +const port = process.env.PORT || 3000 +app.listen(port, () => { + console.log(`Listen on ${port}`) +}) diff --git a/src/templates/app.py.ejs b/src/templates/app.py.ejs new file mode 100644 index 0000000..a1eb08d --- /dev/null +++ b/src/templates/app.py.ejs @@ -0,0 +1,33 @@ +<% + +// "custom_routes.py" => "custom_router" +function fileName2routerName(filename) { + return filename.replace(/_routes\.py$/, '_router') +} + +// "custom_routes.py" => "custom_routes" +function removeExtension(filename) { + return filename.replace(/\.py$/, '') +} + +-%> +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from routes import router +<% customRouteFilenames.forEach(filename => { %> +from <%= removeExtension(filename) %> import router as <%= fileName2routerName(filename) %> +<% }) -%> + +app = FastAPI() + +@app.exception_handler(Exception) +async def exception_handler(request: Request, ex: Exception): + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"error": "Internal Server Error"} + ) + +app.include_router(router) +<% customRouteFilenames.forEach(filename => { %> +app.include_router(<%= fileName2routerName(filename) %>) +<% }) -%> diff --git a/src/templates/app.ts.ejs b/src/templates/app.ts.ejs new file mode 100644 index 0000000..3079eeb --- /dev/null +++ b/src/templates/app.ts.ejs @@ -0,0 +1,65 @@ +<% +// "custom_routes.ts" => "custom_routes" +function removeExtension(filename) { + return filename.replace(/\.ts$/, '') +} +-%> +import express from 'express' +import { NextFunction, Request, Response } from 'express' +import mysql from 'mysql' + +const routes = require('./routes') +<% customRouteFilenames.forEach(filename => { + const routerName = removeExtension(filename) +-%> +const <%- routerName %> = require('./<%- routerName %>') +<% }) -%> + +const app = express() +app.use(express.json()) + +const pool = mysql.createPool({ + connectionLimit: 2, + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + // Support of named placeholders (https://github.com/mysqljs/mysql#custom-format) + queryFormat: function(this: mysql.Pool, query, values) { + if (!values) { + return query + } +<%# See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_function_as_the_replacement -%> + return query.replace(/\:(\w+)/g, function(this: mysql.Pool, matchedSubstring: string, capturedValue: string) { + if (values.hasOwnProperty(capturedValue)) { + return this.escape(values[capturedValue]) + } + return matchedSubstring + }.bind(this)) + }, +<%# LATER: add it only when there is at least one DTO with boolean type -%> + // Support for conversion from TINYINT(1) to boolean (https://github.com/mysqljs/mysql#custom-type-casting) + typeCast: function(field, next) { + if (field.type === 'TINY' && field.length === 1) { + return field.string() === '1' + } + return next() + } +}) + +routes.register(app, pool) +<% customRouteFilenames.forEach(filename => { + const routerName = removeExtension(filename) +-%> +<%- routerName %>.register(app, pool) +<% }) -%> + +app.use((error: any, req: Request, res: Response, next: NextFunction) => { + console.error(error) + res.status(500).json({ "error": "Internal Server Error" }) +}) + +const port = process.env.PORT || 3000 +app.listen(port, () => { + console.log(`Listen on ${port}`) +}) diff --git a/src/templates/db.py.ejs b/src/templates/db.py.ejs new file mode 100644 index 0000000..be8065b --- /dev/null +++ b/src/templates/db.py.ejs @@ -0,0 +1,11 @@ +import os +import psycopg2 + + +async def db_connection(): + return psycopg2.connect( + database=os.getenv('DB_NAME'), + user=os.getenv('DB_USER'), + password=os.getenv('DB_PASSWORD'), + host=os.getenv('DB_HOST', 'localhost'), + port=5432) diff --git a/src/templates/go.mod.ejs b/src/templates/go.mod.ejs new file mode 100644 index 0000000..0df3f33 --- /dev/null +++ b/src/templates/go.mod.ejs @@ -0,0 +1,9 @@ +module main + +go 1.14 + +require ( + github.com/go-chi/chi v4.1.2+incompatible + github.com/go-sql-driver/mysql v1.5.0 + github.com/jmoiron/sqlx v1.2.0 +) diff --git a/src/templates/package.json.ejs b/src/templates/package.json.ejs index 555a000..523c7ea 100644 --- a/src/templates/package.json.ejs +++ b/src/templates/package.json.ejs @@ -2,11 +2,23 @@ "name": "<%- projectName %>", "version": "1.0.0", "scripts": { +<% if (lang === 'js') { -%> "start": "node app.js" +<% } else if (lang === 'ts') { -%> + "build": "npx tsc", + "start": "node dist/app.js" +<% } -%> }, "dependencies": { - "body-parser": "~1.19.0", "express": "~4.17.1", "mysql": "~2.18.1" +<% if (lang === 'ts') { -%> + }, + "devDependencies": { +<%# Generated by: npm install --save-dev typescript @types/express @types/mysql -%> + "@types/express": "~4.17.17", + "@types/mysql": "~2.15.21", + "typescript": "~5.1.6" +<% } -%> } } diff --git a/src/templates/requirements.txt.ejs b/src/templates/requirements.txt.ejs new file mode 100644 index 0000000..271ccad --- /dev/null +++ b/src/templates/requirements.txt.ejs @@ -0,0 +1,3 @@ +fastapi===0.83.0; python_version >= "3.6" +uvicorn==0.18.3 +psycopg2-binary==2.9.3 diff --git a/src/templates/routes.go.ejs b/src/templates/routes.go.ejs new file mode 100644 index 0000000..cd33e9b --- /dev/null +++ b/src/templates/routes.go.ejs @@ -0,0 +1,330 @@ +package main + +import "database/sql" +import "encoding/json" +import "fmt" +import "io" +import "net/http" +import "os" +<%# LATER: add it only when there is at least one parameter of boolean type -%> +import "strconv" +import "github.com/go-chi/chi" +import "github.com/jmoiron/sqlx" + +<% +// {'columns': +// [ +// { +// expr: { type: 'column_ref', table: null, column: 'name_ru' }, +// as: 'nameRu' +// } +// ] +// } => [ 'nameRu' ] +function extractSelectParameters(queryAst) { + return queryAst.columns + .map(column => column.as !== null ? column.as : column.expr.column) +} + +// {'values': +// [ +// { +// type: 'expr_list', +// value: [ { type: 'param', value: 'user_id' } ] +// } +// ] +// } => [ 'user_id' ] +function extractInsertValues(queryAst) { + const values = queryAst.values.flatMap(elem => elem.value) + .map(elem => elem.type === 'param' ? elem.value : null) + .filter(elem => elem) // filter out nulls + return Array.from(new Set(values)) +} + +// {'set': +// [ +// { +// column: 'updated_by', +// value: { type: 'param', value: 'user_id' }, +// table: null +// } +// ] +// } => [ 'user_id' ] +function extractUpdateValues(queryAst) { + // LATER: distinguish between b.param and q.param and extract only first + return queryAst.set.map(elem => elem.value.type === 'param' ? elem.value.value : null) + .filter(value => value) // filter out nulls +} + +// LATER: consider taking into account b.params from WHERE clause +function extractProperties(queryAst) { + if (queryAst.type === 'select') { + return extractSelectParameters(queryAst) + } + + if (queryAst.type === 'insert') { + return extractInsertValues(queryAst) + } + + if (queryAst.type === 'update') { + return extractUpdateValues(queryAst) + } + + return [] +} + +function findOutType(fieldsInfo, fieldName) { + const fieldType = retrieveType(fieldsInfo, fieldName) + if (fieldType === 'integer') { + return '*int' + } + if (fieldType === 'boolean') { + return '*bool' + } + return '*string' +} + +function addTypes(props, fieldsInfo) { + return props.map(prop => { + return { + "name": prop, + "type": findOutType(fieldsInfo, prop), + } + }) +} + +function query2dto(parser, method) { + const query = removePlaceholders(method.query) + const queryAst = parser.astify(query) + const props = extractProperties(queryAst) + if (props.length === 0) { + console.warn('Could not create DTO for query:', formatQueryAsSingleLine(query)) + console.debug('Query AST:') + console.debug(queryAst) + return null + } + const fieldsInfo = method.dto && method.dto.fields ? method.dto.fields : {} + const propsWithTypes = addTypes(props, fieldsInfo) + const hasName = method.dto && method.dto.name && method.dto.name.length > 0 + const name = hasName ? method.dto.name : "Dto" + ++globalDtoCounter + return { + "name": name, + "hasUserProvidedName": hasName, + "props": propsWithTypes, + // max lengths are needed for proper formatting + "maxFieldNameLength": lengthOfLongestString(props.map(el => el.indexOf('_') < 0 ? el : el.replace(/_/g, ''))), + "maxFieldTypeLength": lengthOfLongestString(propsWithTypes.map(el => el.type)), + // required for de-duplication + // [ {name:foo, type:int}, {name:bar, type:string} ] => "foo=int bar=string" + // LATER: sort before join + "signature": propsWithTypes.map(field => `${field.name}=${field.type}`).join(' ') + } +} + +function dto2struct(dto) { + let result = `type ${dto.name} struct {\n` + dto.props.forEach(prop => { + const fieldName = capitalize(snake2camelCase(prop.name)).padEnd(dto.maxFieldNameLength) + const fieldType = prop.type.padEnd(dto.maxFieldTypeLength) + result += `\t${fieldName} ${fieldType} \`json:"${prop.name}" db:"${prop.name}"\`\n` + }) + result += '}\n' + + return result +} + +let globalDtoCounter = 0 + +const dtoCache = {} +const namedDtoCache = {} + +function cacheDto(dto) { + if (dto.hasUserProvidedName) { + namedDtoCache[dto.signature] = dto.name + } else { + dtoCache[dto.signature] = dto.name + } + return dto +} + +function dtoInCache(dto) { + const existsNamed = namedDtoCache.hasOwnProperty(dto.signature) + // always prefer user specified name even when we have a similar DTO in cache for generated names + if (dto.hasUserProvidedName) { + return existsNamed + } + // prefer to re-use named DTO + return existsNamed || dtoCache.hasOwnProperty(dto.signature) +} + +function obtainDtoName(dto) { + const cacheKey = dto.signature + return namedDtoCache.hasOwnProperty(cacheKey) ? namedDtoCache[cacheKey] : dto.name +} + +const verbs_with_dto = [ 'get', 'post', 'put' ] +endpoints.forEach(function(endpoint) { + const dtos = endpoint.methods + .filter(method => method.query) // filter out aggregated_queries for a while (see #17) + .filter(method => verbs_with_dto.includes(method.verb)) + .map(method => query2dto(sqlParser, method)) + .filter(elem => elem) // filter out nulls + .filter(dto => !dtoInCache(dto)) + .map(dto => dto2struct(cacheDto(dto))) + .forEach(struct => { +-%> +<%- struct %> +<% + }) +}) +-%> +<%# LATER: add it only when there is at least one parameter of boolean type -%> +func parseBoolean(value string) bool { + boolValue, err := strconv.ParseBool(value) + if err != nil { + boolValue = false + } + return boolValue +} + +func registerRoutes(r chi.Router, db *sqlx.DB) { +<% +endpoints.forEach(function(endpoint) { + const path = convertPathPlaceholders(endpoint.path) + + endpoint.methods.forEach(function(method) { + if (!method.query) { + // filter out aggregated_queries for a while (see #17) + return + } + + const sql = formatQueryForJs(method.query, 12) + + // define before "if", to make it available later + let dataType + if (method.name !== 'delete') { + const dto = query2dto(sqlParser, method) + // LATER: do we really need signature and cache? + dataType = obtainDtoName(dto) + } + + const params = extractParamsFromQuery(method.query) + const hasGetOne = method.name === 'get' + const hasGetMany = method.name === 'get_list' + if (hasGetOne || hasGetMany) { + const resultVariableDeclaration = hasGetMany + ? `result := []${dataType}\{\}` + : `var result ${dataType}` + + const queryFunction = hasGetOne ? 'Get' : 'Select' + // LATER: handle only particular method (get/post/put) + // LATER: include method/path into an error message +%> + r.Get("<%- path %>", func(w http.ResponseWriter, r *http.Request) { +<% + if (params.length > 0) { +-%> + stmt, err := db.PrepareNamed(<%- sql %>) + if err != nil { + fmt.Fprintf(os.Stderr, "PrepareNamed failed: %v\n", err) + internalServerError(w) + return + } + + <%- resultVariableDeclaration %> + args := map[string]interface{}{ + <%- formatParamsAsGolangMap(params, method) %> + } + err = stmt.<%- queryFunction %>(&result, args) +<% } else { -%> + <%- resultVariableDeclaration %> + err := db.<%- queryFunction %>( + &result,<%- sql %>) +<% } -%> + switch err { + case sql.ErrNoRows: + w.WriteHeader(http.StatusNotFound) + case nil: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&result) + default: + fmt.Fprintf(os.Stderr, "<%- queryFunction %> failed: %v\n", err) + internalServerError(w) + } + }) +<% + } + if (method.name === 'post') { +%> + r.Post("<%- path %>", func(w http.ResponseWriter, r *http.Request) { + var body <%- dataType %> + json.NewDecoder(r.Body).Decode(&body) + + args := map[string]interface{}{ + <%- formatParamsAsGolangMap(params, method) %> + } + _, err := db.NamedExec(<%- sql %>, + args, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "NamedExec failed: %v\n", err) + internalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) +<% + } + if (method.name === 'put') { +%> + r.Put("<%- path %>", func(w http.ResponseWriter, r *http.Request) { + var body <%- dataType %> + json.NewDecoder(r.Body).Decode(&body) + + args := map[string]interface{}{ + <%- formatParamsAsGolangMap(params, method) %> + } + _, err := db.NamedExec(<%- sql %>, + args, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "NamedExec failed: %v\n", err) + internalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) +<% + } + if (method.name === 'delete') { +%> + r.Delete("<%- path %>", func(w http.ResponseWriter, r *http.Request) { + args := map[string]interface{}{ + <%- formatParamsAsGolangMap(params, method) %> + } + _, err := db.NamedExec(<%- sql %>, + args, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "NamedExec failed: %v\n", err) + internalServerError(w) + return + } + + w.WriteHeader(http.StatusNoContent) + }) +<% + } + }) +}) +%> +} + +<%# IMPORTANT: WriteHeader() must be called after w.Header() -%> +<%# w.Write() vs io.WriteString(): https://stackoverflow.com/questions/37863374/whats-the-difference-between-responsewriter-write-and-io-writestring -%> +func internalServerError(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, `{"error":"Internal Server Error"}`) +} diff --git a/src/templates/routes.js.ejs b/src/templates/routes.js.ejs index 267f7dc..9e00054 100644 --- a/src/templates/routes.js.ejs +++ b/src/templates/routes.js.ejs @@ -1,85 +1,91 @@ -const register = (app, pool) => { +<%# LATER: add it only when there is at least one parameter of boolean type -%> +const parseBoolean = (value) => { + return value === 'true' +} +const register = (app, pool) => { <% endpoints.forEach(function(endpoint) { - const hasGetOne = endpoint.hasOwnProperty('get'); - const hasGetMany = endpoint.hasOwnProperty('get_list'); - if (hasGetOne || hasGetMany) { - const sql = hasGetOne ? endpoint.get : endpoint.get_list; - const params = extractParams(sql); + const path = endpoint.path + + endpoint.methods.forEach(function(method) { + if (!method.query) { + // filter out aggregated_queries for a while (see #17) + return + } + const hasGetOne = method.name === 'get' + const hasGetMany = method.name === 'get_list' + const sql = formatQueryForJs(method.query, 12) + const params = extractParamsFromQuery(method.query) + const formattedParams = formatParamsAsJavaScriptObject(params, method) + + if (hasGetOne || hasGetMany) { %> -app.get('<%- endpoint.path %>', (req, res) => { - pool.query( - <%- formatQuery(sql) %>,<%- params.length > 0 ? '\n ' + formatParams(params) + ',' : '' %> - (err, rows, fields) => { - if (err) { - throw err - } -<% if (hasGetMany) { -%> - res.json(rows) -<% } else { -%> - if (rows.length === 0) { - res.type('application/json').status(404).end() - return + app.get('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } +<% if (hasGetMany) { -%> + res.json(rows) +<% } else { -%> + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) +<% } -%> } - res.json(rows[0]) -<% } -%> - } - ) -}) + ) + }) <% - } - if (endpoint.hasOwnProperty('post')) { - const params = extractParams(endpoint.post); + } + if (method.name === 'post') { %> -app.post('<%- endpoint.path %>', (req, res) => { - pool.query( - <%- formatQuery(endpoint.post) %>,<%- params.length > 0 ? '\n ' + formatParams(params) + ',' : '' %> - (err, rows, fields) => { - if (err) { - throw err + app.post('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) } - res.sendStatus(204) - } - ) -}) + ) + }) <% - } - if (endpoint.hasOwnProperty('put')) { - const params = extractParams(endpoint.put); + } + if (method.name === 'put') { %> -app.put('<%- endpoint.path %>', (req, res) => { - pool.query( - <%- formatQuery(endpoint.put) %>,<%- params.length > 0 ? '\n ' + formatParams(params) + ',' : '' %> - (err, rows, fields) => { - if (err) { - throw err + app.put('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) } - res.sendStatus(204) - } - ) -}) + ) + }) <% - } - if (endpoint.hasOwnProperty('delete')) { - const params = extractParams(endpoint.delete); + } + if (method.name === 'delete') { %> -app.delete('<%- endpoint.path %>', (req, res) => { - pool.query( - <%- formatQuery(endpoint.delete) %>,<%- params.length > 0 ? '\n ' + formatParams(params) + ',' : '' %> - (err, rows, fields) => { - if (err) { - throw err + app.delete('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) } - res.sendStatus(204) + ) + }) +<% } - ) + }) }) -<% - } -}); %> - } -exports.register = register; +exports.register = register diff --git a/src/templates/routes.py.ejs b/src/templates/routes.py.ejs new file mode 100644 index 0000000..fe5c12d --- /dev/null +++ b/src/templates/routes.py.ejs @@ -0,0 +1,335 @@ +import psycopg2 +import psycopg2.extras + +<%# https://fastapi.tiangolo.com/reference/status/ -%> +from fastapi import APIRouter, Depends, HTTPException, status + +<%# LATER: add only when POST/PUT endpoints are present -%> +from pydantic import BaseModel + +<%# LATER: add only when POST/PUT endpoints are present -%> +from typing import Optional + +from db import db_connection + +router = APIRouter() +<% +// { "get", "/v1/categories/:categoryId" } => "get_v1_categories_category_id" +function generate_method_name(method, path) { + const name = camel2snakeCase(path).replace(/\//g, '_').replace(/[^_a-z0-9]/g, '') + return `${method}${name}` +} + +// "INSERT INTO ... VALUES(:categoryId)" => "INSERT INTO ... VALUES(%(categoryId)s)" +// See: https://www.psycopg.org/docs/usage.html#passing-parameters-to-sql-queries +function convertToPsycopgNamedArguments(sql) { + return sql.replace(/(? "/categories/{categoryId}" +function convertToFastApiPath(path) { + return path.replace(/:([_a-zA-Z]+)/g, '{$1}') +} + +// LATER: reduce duplication with routes.go.ejs +// {'values': +// [ +// { +// type: 'expr_list', +// value: [ { type: 'param', value: 'user_id' } ] +// } +// ] +// } => [ 'user_id' ] +function extractInsertValues(queryAst) { + const values = queryAst.values.flatMap(elem => elem.value) + .map(elem => elem.type === 'param' ? elem.value : null) + .filter(elem => elem) // filter out nulls + return Array.from(new Set(values)) +} + +// LATER: reduce duplication with routes.go.ejs +// {'set': +// [ +// { +// column: 'updated_by', +// value: { type: 'param', value: 'user_id' }, +// table: null +// } +// ] +// } => [ 'user_id' ] +function extractUpdateValues(queryAst) { + // LATER: distinguish between b.param and q.param and extract only first + return queryAst.set.map(elem => elem.value.type === 'param' ? elem.value.value : null) + .filter(value => value) // filter out nulls +} + +// LATER: reduce duplication with routes.go.ejs +function extractProperties(queryAst) { + if (queryAst.type === 'insert') { + return extractInsertValues(queryAst) + } + if (queryAst.type === 'update') { + return extractUpdateValues(queryAst) + } + return [] +} + +function findOutType(fieldsInfo, fieldName) { + const fieldType = retrieveType(fieldsInfo, fieldName) + if (fieldType === 'integer') { + return 'int' + } + if (fieldType === 'boolean') { + return 'bool' + } + return 'str' +} + +// "q.title" => "q.title: str" +// "q.active" => "q.active: bool" +// "q.age" => "q.age: int" +// "p.id" => "p.id" +// "b.name" => "b.name" +function appendVariableTypeToQueryParam(paramsInfo, varName) { + if (varName.startsWith('q.')) { + return `${varName}: ${findOutType(paramsInfo, stipOurPrefixes(varName))}` + } + return varName +} + +// LATER: reduce duplication with routes.go.ejs +function addTypes(props, fieldsInfo) { + return props.map(prop => { + return { + "name": prop, + "type": findOutType(fieldsInfo, prop), + } + }) +} + +// LATER: reduce duplication with routes.go.ejs +function query2dto(parser, method) { + const query = removePlaceholders(method.query) + const queryAst = parser.astify(query) + const props = extractProperties(queryAst) + if (props.length === 0) { + console.warn('Could not create DTO for query:', formatQueryAsSingleLine(query)) + console.debug('Query AST:') + console.debug(queryAst) + return null + } + const fieldsInfo = method.dto && method.dto.fields ? method.dto.fields : {} + const propsWithTypes = addTypes(props, fieldsInfo) + const hasName = method.dto && method.dto.name && method.dto.name.length > 0 + const name = hasName ? method.dto.name : "Dto" + ++globalDtoCounter + return { + "name": name, + "hasUserProvidedName": hasName, + "props": propsWithTypes, + // required for de-duplication + // [ {name:foo, type:int}, {name:bar, type:string} ] => "foo=int bar=string" + // LATER: sort before join + "signature": propsWithTypes.map(field => `${field.name}=${field.type}`).join(' ') + } +} + +// https://fastapi.tiangolo.com/tutorial/body/ +function dto2model(dto) { + let result = `class ${dto.name}(BaseModel):\n` + dto.props.forEach(prop => { + result += ` ${prop.name}: Optional[${prop.type}] = None\n` + }) + return result +} + +let globalDtoCounter = 0 +const dtoCache = {} +const namedDtoCache = {} + +// LATER: reduce duplication with routes.go.ejs +function cacheDto(dto) { + if (dto.hasUserProvidedName) { + namedDtoCache[dto.signature] = dto.name + } else { + dtoCache[dto.signature] = dto.name + } + return dto +} + +// LATER: reduce duplication with routes.go.ejs +function dtoInCache(dto) { + const existsNamed = namedDtoCache.hasOwnProperty(dto.signature) + // always prefer user specified name even when we have a similar DTO in cache for generated names + if (dto.hasUserProvidedName) { + return existsNamed + } + // prefer to re-use named DTO + return existsNamed || dtoCache.hasOwnProperty(dto.signature) +} + +function obtainDtoName(dto) { + const cacheKey = dto.signature + return namedDtoCache.hasOwnProperty(cacheKey) ? namedDtoCache[cacheKey] : dto.name +} + +// Generate models +const verbs_with_dto = [ 'post', 'put' ] +endpoints.forEach(function(endpoint) { + const dtos = endpoint.methods + .filter(method => verbs_with_dto.includes(method.verb)) + .map(method => query2dto(sqlParser, method)) + .filter(elem => elem) // filter out nulls + .filter(dto => !dtoInCache(dto)) + .map(dto => dto2model(cacheDto(dto))) + .forEach(model => { +%> +<%- model -%> +<% + }) +}) + +// Generate endpoints +endpoints.forEach(function(endpoint) { + const path = convertToFastApiPath(endpoint.path) + const argsFromPath = extractParamsFromPath(endpoint.path) + + endpoint.methods.forEach(function(method) { + const hasGetOne = method.name === 'get' + const hasGetMany = method.name === 'get_list' + const pythonMethodName = generate_method_name(method.name, path) + + // LATER: add support for aggregated_queries (#17) + const queryParamsInfo = method.params && method.params.query ? method.params.query : {} + const argsFromQuery = method.query ? extractParamsFromQuery(method.query).map(param => appendVariableTypeToQueryParam(queryParamsInfo, param)).map(stipOurPrefixes) : [] + + // define before "if", to make them available later + let methodArgs + let sql + let formattedParams + let model + if (method.name === 'post' || method.name === 'put' || method.name === 'delete') { + sql = convertToPsycopgNamedArguments(formatQueryForPython(method.query, 20)) + const params = extractParamsFromQuery(method.query) + formattedParams = formatParamsAsPythonDict(params) + + if (method.name === 'post' || method.name === 'put') { + const dto = query2dto(sqlParser, method) + // LATER: do we really need signature and cache? + model = obtainDtoName(dto) + methodArgs = [ `body: ${model}`, ...argsFromPath, 'conn=Depends(db_connection)' ] + + } else if (method.name === 'delete') { + methodArgs = [ ...argsFromPath, 'conn=Depends(db_connection)' ] + } + } + + if (hasGetOne || hasGetMany) { + methodArgs = Array.from(new Set([...argsFromPath, ...argsFromQuery, 'conn=Depends(db_connection)'])) + + const queriesWithNames = [] + if (method.query) { + queriesWithNames.push({ "result" : method.query }) + } else if (method.aggregated_queries) { + for (const [key, value] of Object.entries(method.aggregated_queries)) { + queriesWithNames.push({ [key]: value }) + } + } + + const queries = [] + queriesWithNames.forEach(queryWithName => { + for (const [name, query] of Object.entries(queryWithName)) { + const sql = convertToPsycopgNamedArguments(formatQueryForPython(query, 20)) + const params = extractParamsFromQuery(query) + const formattedParams = formatParamsAsPythonDict(params) + queries.push({ [name]: { sql : sql, formattedParams: formattedParams }}) + } + }) +%> + +@router.get('<%- path %>') +def <%- pythonMethodName %>(<%- methodArgs.join(', ') %>): + try: +<%# + https://www.psycopg.org/docs/usage.html#with-statement + https://www.psycopg.org/docs/extras.html#dictionary-like-cursor + https://stackoverflow.com/questions/45399347/dictcursor-vs-realdictcursor +-%> + with conn: +<% if (hasGetOne && queries.length > 1) { /* we can omit cursor_factory but in this case we might get an unused import */-%> + with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur: + result = {} +<% queries.forEach(queryInfo => { + for (const [name, query] of Object.entries(queryInfo)) { +-%> + cur.execute(<%- query.sql %><%- query.formattedParams %>) + result['<%- name %>'] = cur.fetchone()[0] +<% } + }) +-%> + return result +<% + } else { + const query = queries[0].result +-%> + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(<%- query.sql %><%- query.formattedParams %>) +<% if (hasGetMany) { -%> + return cur.fetchall() +<% } else { /* GET with a single result */ -%> + result = cur.fetchone() + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return result +<% + } + } +-%> + finally: + conn.close() +<% + } + if (method.name === 'post') { +%> + +@router.post('<%- path %>', status_code=status.HTTP_204_NO_CONTENT) +def <%- pythonMethodName %>(<%- methodArgs.join(', ') %>): + try: + with conn: + with conn.cursor() as cur: + cur.execute(<%- sql %><%- formattedParams %>) + finally: + conn.close() +<% + + } + if (method.name === 'put') { +%> + +@router.put('<%- path %>', status_code=status.HTTP_204_NO_CONTENT) +def <%- pythonMethodName %>(<%- methodArgs.join(', ') %>): + try: + with conn: + with conn.cursor() as cur: + cur.execute(<%- sql %><%- formattedParams %>) + finally: + conn.close() +<% + + } + if (method.name === 'delete') { +%> + +@router.delete('<%- path %>', status_code=status.HTTP_204_NO_CONTENT) +def <%- pythonMethodName %>(<%- methodArgs.join(', ') %>): + try: + with conn: + with conn.cursor() as cur: + cur.execute(<%- sql %><%- formattedParams %>) + finally: + conn.close() +<% + + } + }) +}) +%> \ No newline at end of file diff --git a/src/templates/routes.ts.ejs b/src/templates/routes.ts.ejs new file mode 100644 index 0000000..ea8043e --- /dev/null +++ b/src/templates/routes.ts.ejs @@ -0,0 +1,94 @@ +import { Express, NextFunction, Request, Response } from 'express' +import { Pool } from 'mysql' + +<%# LATER: add it only when there is at least one parameter of boolean type -%> +const parseBoolean = (value: any) => { + return typeof value === 'string' && value === 'true' +} + +const register = (app: Express, pool: Pool) => { +<% +endpoints.forEach(function(endpoint) { + const path = endpoint.path + + endpoint.methods.forEach(function(method) { + if (!method.query) { + // filter out aggregated_queries for a while (see #17) + return + } + const hasGetOne = method.name === 'get' + const hasGetMany = method.name === 'get_list' + const sql = formatQueryForJs(method.query, 12) + const params = extractParamsFromQuery(method.query) + const formattedParams = formatParamsAsJavaScriptObject(params, method) + + if (hasGetOne || hasGetMany) { +%> + app.get('<%- path %>', (req: Request, res: Response, next: NextFunction) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } +<% if (hasGetMany) { -%> + res.json(rows) +<% } else { -%> + if (rows.length === 0) { + res.status(404).end() + return + } + res.json(rows[0]) +<% } -%> + } + ) + }) +<% + } + if (method.name === 'post') { +%> + app.post('<%- path %>', (req: Request, res: Response, next: NextFunction) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) +<% + } + if (method.name === 'put') { +%> + app.put('<%- path %>', (req: Request, res: Response, next: NextFunction) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) +<% + } + if (method.name === 'delete') { +%> + app.delete('<%- path %>', (req: Request, res: Response, next: NextFunction) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) +<% + } + }) +}) +%> +} + +exports.register = register diff --git a/src/templates/tsconfig.json.ejs b/src/templates/tsconfig.json.ejs new file mode 100644 index 0000000..86be3a2 --- /dev/null +++ b/src/templates/tsconfig.json.ejs @@ -0,0 +1,110 @@ +<%# Generated by: npx tsc --init --outDir dist -%> +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/tests/crud.hurl b/tests/crud.hurl new file mode 100644 index 0000000..ac18f20 --- /dev/null +++ b/tests/crud.hurl @@ -0,0 +1,95 @@ +# +# Tests for basic CRUD operations +# +# How to run: +# hurl --variable SERVER_URL=http://127.0.0.1:3000 crud.hurl --test +# +# See also: https://hurl.dev and https://github.com/Orange-OpenSource/hurl +# + + +# POST should create an object +POST {{ SERVER_URL }}/v1/categories +{ + "name": "Sport", + "slug": "sport", + "hidden": true, + "user_id": 1 +} +HTTP 204 + +# ensures that it was created +GET {{ SERVER_URL }}/v1/categories/count +HTTP 200 +[Asserts] +jsonpath "$.counter" == 1 + + +# GET should return a value +GET {{ SERVER_URL }}/v1/categories/1 +HTTP 200 +[Asserts] +header "Content-Type" contains "application/json" +jsonpath "$.id" == 1 +jsonpath "$.name" == "Sport" +jsonpath "$.name_ru" == null +jsonpath "$.slug" == "sport" +jsonpath "$.hidden" == true + + +# GET should return a list of values +GET {{ SERVER_URL }}/v1/categories +HTTP 200 +[Asserts] +header "Content-Type" contains "application/json" +jsonpath "$" count == 1 +jsonpath "$[0].id" == 1 +jsonpath "$[0].name" == "Sport" +jsonpath "$[0].name_ru" == null +jsonpath "$[0].slug" == "sport" +jsonpath "$[0].hidden" == true + +GET {{ SERVER_URL }}/v1/categories/search?hidden=true +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].name" == "Sport" +jsonpath "$[0].hidden" == true + + +# PUT should update an object +PUT {{ SERVER_URL }}/v1/categories/1 +{ + "name": "Fauna", + "name_ru": "Фауна", + "slug": "fauna", + "hidden": false, + "user_id": 1 +} +HTTP 204 + +# ensures that it was updated +GET {{ SERVER_URL }}/v1/categories/1 +HTTP 200 +[Asserts] +header "Content-Type" contains "application/json" +jsonpath "$.name" == "Fauna" +jsonpath "$.name_ru" == "Фауна" +jsonpath "$.slug" == "fauna" +jsonpath "$.hidden" == false + +GET {{ SERVER_URL }}/v1/categories/search?hidden=false +HTTP 200 +[Asserts] +jsonpath "$" count == 1 +jsonpath "$[0].name" == "Fauna" +jsonpath "$[0].hidden" == false + + +# DELETE should remove an object +DELETE {{ SERVER_URL }}/v1/categories/1 +HTTP 204 + +# ensures that it was removed +GET {{ SERVER_URL }}/v1/categories/1 +HTTP 404 diff --git a/tests/crud.robot b/tests/crud.robot deleted file mode 100644 index ba7d75b..0000000 --- a/tests/crud.robot +++ /dev/null @@ -1,42 +0,0 @@ -*** Settings *** -Documentation Basic CRUD operations -Library Collections -Library RequestsLibrary -Suite Setup Create Session alias=api url=${SERVER_URL} -Suite Teardown Delete All Sessions - -*** Variables *** -${SERVER_URL} http://host.docker.internal:3000 - -** Test Cases *** -POST should create an object - &{payload}= Create Dictionary name=Sport slug=sport userId=1 - ${response}= Post Request api /v1/categories json=${payload} - Status Should Be 204 ${response} - # checks that it was created - ${response}= Get Request api /v1/categories/count - Status Should Be 200 ${response} - Dictionary Should Contain Item ${response.json()} counter 1 - -GET should return a value - ${response}= Get Request api /v1/categories/count - Status Should Be 200 ${response} - Dictionary Should Contain Item ${response.json()} counter 1 - -GET should return not found - ${response}= Get Request api /v1/categories/100 - Status Should Be 404 ${response} - Should Be Equal ${response.headers['Content-Type']} application/json; charset=utf-8 - -PUT should update an object - &{payload}= Create Dictionary name=Fauna nameRu=Фауна slug=fauna userId=1 - ${response}= Put Request api /v1/categories/1 json=${payload} - Status Should Be 204 ${response} - -DELETE should remove an object - ${response}= Delete Request api /v1/categories/1 - Status Should Be 204 ${response} - # checks that it was removed - ${response}= Get Request api /v1/categories/count - Status Should Be 200 ${response} - Dictionary Should Contain Item ${response.json()} counter 0 diff --git a/tests/misc.hurl b/tests/misc.hurl new file mode 100644 index 0000000..45b2cc5 --- /dev/null +++ b/tests/misc.hurl @@ -0,0 +1,22 @@ +# +# Tests for various operations +# +# How to run: +# hurl --variable SERVER_URL=http://127.0.0.1:3000 --variable skip_500_error_testing=false misc.hurl --test +# + + +# Custom route +GET {{ SERVER_URL }}/custom/route +HTTP 200 +[Asserts] +jsonpath "$.custom" == true + + +GET {{ SERVER_URL }}/custom/exception +[Options] +skip: {{ skip_500_error_testing }} +HTTP 500 +[Asserts] +header "Content-Type" contains "application/json" +jsonpath "$.error" == "Internal Server Error" diff --git a/tests/mise.toml b/tests/mise.toml new file mode 100644 index 0000000..89c7edf --- /dev/null +++ b/tests/mise.toml @@ -0,0 +1,2 @@ +[tools] +hurl = "6.1.1"