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..0438abe --- /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@v3.3.1 # https://github.com/jdx/mise-action + with: + version: 2025.11.11 # [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/README.md b/README.md index cb23563..9e0f2d0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,118 @@ # 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. + +# How to use + +1. Navigate to a project catalog + ```console + $ mkdir new-project + $ cd new-project + ``` + +1. Create a mapping file `endpoints.yaml` + ```console + $ vim endpoints.yaml + - path: /v1/categories + 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: + 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 `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 +
+ 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 +
+ 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** + + While the example used `export` for setting up the environment variables, we don't recommend export variables that way! This was provided as an example to illustrate that an application follows [The Twelve Factors](https://12factor.net/config) and can be configured by passing environment variables. In real life, you will use docker, docker-compose, Kubernetes or other ways to run an app with required environment variables. + + --- + :bulb: **NOTE** + + An app also supports other environment variables: + + * `PORT`: a port to listen (defaults to `3000`) + * `DB_HOST` a database host (defaults to `localhost`) + + --- +
+ +1. Test that it works +
+ Examples for curl + + ```console + $ 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 -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.mysql.sql b/docker/categories.mysql.sql new file mode 100644 index 0000000..78ff33a --- /dev/null +++ b/docker/categories.mysql.sql @@ -0,0 +1,15 @@ +CREATE TABLE `categories` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `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, + `updated_by` int(11) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + UNIQUE KEY `uc_categories_slug` (`slug`), + UNIQUE KEY `name_ru` (`name_ru`) +) ENGINE=InnoDB CHARSET=utf8; 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 new file mode 100644 index 0000000..44d2420 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,101 @@ +# Usage examples: +# +# 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' +# + +services: + mysql: + # https://hub.docker.com/_/mysql + image: mysql:5.7.20 + user: mysql:mysql + environment: + - MYSQL_ROOT_PASSWORD=secret + - MYSQL_USER=test + - MYSQL_PASSWORD=test + - MYSQL_DATABASE=test + volumes: + - ./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/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/express/mysql/package.json b/examples/js/express/mysql/package.json new file mode 100644 index 0000000..0d0fdc8 --- /dev/null +++ b/examples/js/express/mysql/package.json @@ -0,0 +1,11 @@ +{ + "name": "mysql", + "version": "1.0.0", + "scripts": { + "start": "node app.js" + }, + "dependencies": { + "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/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 572115f..e231f22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,252 @@ { "name": "query2app", - "version": "0.0.1", - "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 95618bc..6382885 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,41 @@ { "name": "query2app", - "version": "0.0.1", + "version": "0.0.3", "description": "Generates the endpoints from SQL -> URL mapping", - "main": "index.js", - "scripts": { - "start": "node src/index.js", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "repository": { - "type": "git", - "url": "https://github.com/php-coder/query2app.git" - }, - "bin": { - "query2app": "./src/cli.js" - }, "keywords": [ "sql", "generate", "generator", "skaffolding" ], - "author": "Slava Semushin ", - "license": "GPL-2.0", "bugs": { "url": "https://github.com/php-coder/query2app/issues", "email": "slava.semushin@gmail.com" }, + "repository": { + "type": "git", + "url": "https://github.com/php-coder/query2app.git" + }, + "license": "GPL-2.0", + "author": "Slava Semushin ", + "main": "index.js", + "bin": { + "query2app": "./src/cli.js" + }, + "files": [ + "src/**" + ], + "scripts": { + "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 a52e482..d445c92 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,80 +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 resultFile = 'app.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 resultedCode = await ejs.renderFile( + `${__dirname}/templates/${fileName}.ejs` + ) + + return fsPromises.writeFile(resultFile, resultedCode) +} + +// "-- 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, ' ') + +// "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}') + +// "name_ru" => "nameRu" +// (used only with Golang's go-chi) +const snake2camelCase = (str) => str.replace(/_([a-z])/g, (match, group) => group.toUpperCase()) + +// "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, fileName, config) => { - console.log('Generate', fileName); - const resultFile = path.join(destDir, fileName); +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) { - console.log('GET', endpoint.path, '=>', endpoint.get); + 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 = { + '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/app.js.ejs', + `${__dirname}/templates/routes.${ext}.ejs`, { - "endpoints": config + "endpoints": config, + + // "... 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}},` + }, + + // "SELECT *\n FROM foo WHERE id = :p.id" => "SELECT * FROM foo WHERE id = :id" + "formatQueryAsSingleLine": (query) => { + return removePlaceholders(flattenQuery(removeComments(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) +} - fs.writeFileSync(resultFile, resultedCode); -}; +const createDependenciesDescriptor = async (destDir, { lang }) => { + let fileName + if (lang === 'js' || lang === 'ts') { + fileName = 'package.json' -const createPackageJson = async (destDir, fileName) => { - console.log('Generate', fileName); + } else if (lang === 'go') { + fileName = 'go.mod' - const resultFile = path.join(destDir, fileName); - const projectName = path.basename(destDir); - console.log('Project name:', projectName); + } else if (lang === 'python') { + fileName = 'requirements.txt' + + } else { + return + } + + console.log('Generate', fileName) + + 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 } - ); + ) + + return fsPromises.writeFile(resultFile, minimalPackageJson) +} - fs.writeFileSync(resultFile, minimalPackageJson); -}; +const createDockerfile = async (destDir, lang) => { + const fileName = 'Dockerfile' + console.log('Generate', fileName) + + const resultFile = path.join(destDir, fileName) + + return fsPromises.copyFile(`${__dirname}/templates/${fileName}.${lang}`, resultFile) +} + +const createTypeScriptConfig = async (destDir, lang) => { + if (lang !== 'ts') { + return + } + const fileName = 'tsconfig.json' + console.log('Generate', fileName) -const config = loadConfig(endpointsFile); + const resultFile = path.join(destDir, fileName) -let [,, destDir = '.'] = process.argv; -destDir = path.resolve(process.cwd(), destDir); -console.log('Destination directory:', destDir) + const tsConfigJson = await ejs.renderFile( + `${__dirname}/templates/${fileName}.ejs` + ) -if (!fs.existsSync(destDir)) { - console.log('Create', destDir) - fs.mkdirSync(destDir, {recursive: true}); + return fsPromises.writeFile(resultFile, tsConfigJson) } -createEndpoints(destDir, resultFile, config); +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()) +} -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 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.ejs b/src/templates/app.js.ejs index 6d4e3a4..9379161 100644 --- a/src/templates/app.js.ejs +++ b/src/templates/app.js.ejs @@ -1,30 +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.set('x-powered-by', false) +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 + 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() + } }) -<% endpoints.forEach(function(endpoint) { %> -app.get('<%- endpoint.path %>', (req, res) => { - pool.query('<%- endpoint.get %>', (err, rows, fields) => { - if (err) { - throw err - } - const counter = rows[0].counter - res.json(counter) - }) +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; +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 109e68a..523c7ea 100644 --- a/src/templates/package.json.ejs +++ b/src/templates/package.json.ejs @@ -2,10 +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": { "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 new file mode 100644 index 0000000..9e00054 --- /dev/null +++ b/src/templates/routes.js.ejs @@ -0,0 +1,91 @@ +<%# 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 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, 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]) +<% } -%> + } + ) + }) +<% + } + if (method.name === 'post') { +%> + app.post('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) +<% + } + if (method.name === 'put') { +%> + app.put('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) +<% + } + if (method.name === 'delete') { +%> + app.delete('<%- path %>', (req, res, next) => { + pool.query(<%- sql %>,<%- formattedParams %> + (err, rows, fields) => { + if (err) { + return next(err) + } + res.sendStatus(204) + } + ) + }) +<% + } + }) +}) +%> +} + +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/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"