From 1e569c79f60d06b9c8560ea4ad9ccd45e6b5585a Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 11 Sep 2025 18:28:16 +0800 Subject: [PATCH] first release --- .github/workflows/ci.yml | 77 + .gitignore | 144 ++ CLAUDE.md | 46 + LICENSE | 201 ++ NOTICE | 13 + README.md | 227 ++ cli/create.js | 35 + cli/start.js | 7 + config.d.ts | 875 ++++++++ eslint.config.js | 5 + lib/generator.js | 78 + lib/index.js | 33 + lib/main.py | 86 + lib/plugin.js | 111 + lib/schema.js | 33 + package.json | 45 + renovate.json | 28 + schema.json | 3263 +++++++++++++++++++++++++++++ test/fixtures/hello/main.py | 126 ++ test/fixtures/hello/something.txt | 1 + test/generator.test.js | 113 + test/plugin.test.js | 81 + 22 files changed, 5628 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 README.md create mode 100755 cli/create.js create mode 100755 cli/start.js create mode 100644 config.d.ts create mode 100644 eslint.config.js create mode 100644 lib/generator.js create mode 100644 lib/index.js create mode 100644 lib/main.py create mode 100644 lib/plugin.js create mode 100644 lib/schema.js create mode 100644 package.json create mode 100644 renovate.json create mode 100644 schema.json create mode 100644 test/fixtures/hello/main.py create mode 100644 test/fixtures/hello/something.txt create mode 100644 test/generator.test.js create mode 100644 test/plugin.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..792cf96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: Run Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - 'releases/**' + +env: + CARGO_NET_GIT_FETCH_WITH_CLI: 'true' + # GIT_SSH_COMMAND: 'ssh -o UserKnownHostsFile=/github/home/.ssh/known_hosts -o StrictHostKeyChecking=yes' + +jobs: + ci: + name: CI - Node.js ${{ matrix.node-version }} & Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + node-version: + - '20' + - '22' + - '24' + python-version: + - '3.8' + - '3.9' + - '3.10' + - '3.11' + - '3.12' + - '3.13' + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup SSH Agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: | + ${{ secrets.SSH_PRIVATE_KEY }} + ${{ secrets.HTTP_HANDLER_ACCESS_TOKEN }} + ${{ secrets.HTTP_REWRITER_ACCESS_TOKEN }} + - uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: node-modules-${{ hashFiles('package.json') }} + - uses: pnpm/action-setup@v4 + with: + version: latest + - name: Install dependencies + run: pnpm install + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + # TODO: replace with `pnpm install` when using published dependency + - name: Build python-node manually + run: | + # Configure git to use SSH host aliases for private repos (needed by cargo) + git config --global url."ssh://git@github.com-http-handler/platformatic/http-handler".insteadOf "ssh://git@github.com/platformatic/http-handler" + git config --global url."ssh://git@github.com-http-handler/platformatic/http-handler.git".insteadOf "ssh://git@github.com/platformatic/http-handler.git" + git config --global url."ssh://git@github.com-http-rewriter/platformatic/http-rewriter".insteadOf "ssh://git@github.com/platformatic/http-rewriter" + git config --global url."ssh://git@github.com-http-rewriter/platformatic/http-rewriter.git".insteadOf "ssh://git@github.com/platformatic/http-rewriter.git" + + cd node_modules/@platformatic/python-node + pnpm install --ignore-scripts + pnpm run build + pnpm run build:wasm + pnpm run build:fix + - name: Run Full Test Suite + shell: bash + run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdb1e7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ +.DS_Store + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +plt-python +wordpress + +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..30206d9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is `@platformatic/python` - a Python stackable for Watt that integrates Python applications with the Platformatic framework. It enables serving Python ASGI applications through a Fastify server with proper request/response handling. + +## Development Commands + +- `npm test` - Run tests using Node.js built-in test runner +- `npm run build` - Generate schema.json and config.d.ts from schema definitions +- `npm run ci` - Run linting and tests (assumes lint script exists) + +## Architecture + +The project follows Platformatic's stackable pattern: + +### Core Components + +- **lib/index.js** - Main stackable export with configuration and plugin registration +- **lib/plugin.js** - Fastify plugin that handles Python request routing and execution +- **lib/generator.js** - Code generator for creating new Python stackable projects +- **lib/schema.js** - JSON schema definitions for configuration validation + +### Key Architecture Patterns + +1. **Stackable Integration**: Extends `@platformatic/service` with Python-specific functionality +2. **Request Handling**: All HTTP methods are captured by wildcard routes and forwarded to Python via `@platformatic/python-node` +3. **Static File Serving**: Non-Python files in docroot are served statically with `@fastify/static` +4. **Header Processing**: HTTP headers are capitalized for Python compatibility +5. **Configuration Schema**: Uses JSON schema with automatic TypeScript generation + +### Generated Project Structure + +When using the generator, projects include: +- `public/` directory as Python docroot with `main.py` containing a basic ASGI app +- `platformatic.json` configuration file +- `.env` and `.env.sample` for environment variables +- Node.js v22.18.0+ and Python 3.8+ requirements + +### Testing Approach + +- Uses Node.js built-in test runner (`node --test`) +- Tests cover generator functionality, configuration validation, and file generation +- Test fixtures in `test/fixtures/` for integration testing diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..f602054 --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ + Copyright 2025 Platformatic + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5c0516 --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# @platformatic/python + +A Python stackable for [Platformatic](https://platformatic.dev/) that enables running Python ASGI applications within the Platformatic ecosystem. This package integrates Python execution with Fastify servers, allowing you to serve Python applications alongside Node.js applications. + +## Features + +- 🚀 Run Python ASGI applications within Platformatic services +- 🔄 Automatic request/response handling between Node.js and Python +- 📁 Static file serving for non-Python assets +- ⚡ Hot reloading during development +- 🛠️ Code generation for new Python projects +- 🔧 Environment-based configuration + +## Requirements + +- Node.js >= 22.18.0 +- Python >= 3.8 +- The Python runtime is built thanks to [`@platformatic/python-node`](https://github.com/platformatic/python-node). + +## Installation + +```bash +npm install @platformatic/python +``` + +## Quick Start + +### Create a New Python Project + +```bash +npx --package=@platformatic/python create-platformatic-python --dir my-python-app --port 3042 +cd my-python-app +npm install +npm start +``` + +### CLI Options + +- `--dir` - Target directory (default: `plt-python`) +- `--port` - Server port (default: `3042`) +- `--hostname` - Server hostname (default: `0.0.0.0`) +- `--main` - Main Python file (default: `main.py`) + +## Configuration + +The stackable uses a `platformatic.json` configuration file: + +```json +{ + "$schema": "https://schemas.platformatic.dev/@platformatic/python/0.7.0.json", + "module": "@platformatic/python", + "python": { + "docroot": "public", + "appTarget": "main:app" + }, + "server": { + "hostname": "{PLT_SERVER_HOSTNAME}", + "port": "{PORT}", + "logger": { "level": "{PLT_SERVER_LOGGER_LEVEL}" } + }, + "watch": true +} +``` + +### Configuration Options + +#### python +- `docroot` (string, required) - Path to the root directory containing Python files +- `appTarget` (string, optional) - The Python module and function to load in the format `module:function` (default: `main:app`) + +#### server +Standard Platformatic server configuration options are supported. + +## Project Structure + +A generated Python project includes: + +``` +my-python-app/ +├── public/ +│ └── main.py # Main Python ASGI application +├── .env # Environment variables +├── .env.sample # Environment template +├── .gitignore +├── package.json +└── platformatic.json # Platformatic configuration +``` + +## Development + +### Available Scripts + +- `npm start` - Start the development server +- `npm test` - Run tests +- `npm run build` - Build schema and types + +### Environment Variables + +- `PLT_SERVER_HOSTNAME` - Server hostname (default: `0.0.0.0`) +- `PORT` - Server port (default: `3042`) +- `PLT_SERVER_LOGGER_LEVEL` - Log level (default: `info`) + +## How It Works + +1. **Request Routing**: All HTTP requests are captured by wildcard routes +2. **Python Execution**: Requests are forwarded to Python ASGI applications via `@platformatic/python-node` +3. **Static Files**: Non-Python files in the docroot are served statically +4. **Response Handling**: Python ASGI responses are processed and returned through Fastify + +## API + +### Stackable Export + +```javascript +import { stackable } from '@platformatic/python' +// or +import python from '@platformatic/python' +``` + +### Generator + +```javascript +import { Generator } from '@platformatic/python' + +const generator = new Generator() +generator.setConfig({ + targetDirectory: './my-app', + port: 3042, + hostname: '0.0.0.0' +}) +await generator.run() +``` + +## Examples + +### Basic Python ASGI Application + +```python +# public/main.py +import json +from datetime import datetime + +async def app(scope, receive, send): + """ + Basic ASGI application + """ + if scope["type"] == "http": + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'application/json'], + ], + }) + + response_data = { + "message": "Hello from Python!", + "timestamp": datetime.now().isoformat() + } + + await send({ + 'type': 'http.response.body', + 'body': json.dumps(response_data).encode('utf-8'), + }) +``` + +### Handling POST Requests + +```python +# public/api.py +import json + +async def app(scope, receive, send): + """ + ASGI application that handles POST requests + """ + if scope["type"] == "http": + method = scope["method"] + + if method == "POST": + # Read the request body + body = b'' + while True: + message = await receive() + if message['type'] == 'http.request': + body += message.get('body', b'') + if not message.get('more_body', False): + break + + # Parse JSON body + try: + input_data = json.loads(body.decode('utf-8')) + except: + input_data = {} + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'application/json'], + ], + }) + + response_data = { + "received": input_data, + "method": method + } + + await send({ + 'type': 'http.response.body', + 'body': json.dumps(response_data).encode('utf-8'), + }) +``` + +## Contributing + +This project is part of the [Platformatic](https://github.com/platformatic) ecosystem. Please refer to the main repository for contribution guidelines. + +## License + +Apache-2.0 + +## Support + +- [GitHub Issues](https://github.com/platformatic/python/issues) +- [Platformatic Documentation](https://docs.platformatic.dev/) +- [Community Discord](https://discord.gg/platformatic) diff --git a/cli/create.js b/cli/create.js new file mode 100755 index 0000000..a277add --- /dev/null +++ b/cli/create.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import { join } from 'node:path' +import { parseArgs } from 'node:util' +import { Generator } from '../lib/generator.js' + +async function execute () { + const args = parseArgs({ + args: process.argv.slice(2), + options: { + dir: { + type: 'string', + default: join(process.cwd(), 'plt-python') + }, + port: { type: 'string', default: '3042' }, + hostname: { type: 'string', default: '0.0.0.0' }, + main: { type: 'string', default: 'main.py' }, + } + }) + + const generator = new Generator() + + generator.setConfig({ + targetDirectory: args.values.dir, + port: parseInt(args.values.port), + hostname: args.values.hostname, + main: args.values.main, + }) + + await generator.run() + + console.log('Application created successfully! Run `npm run start` to start an application.') +} + +execute() diff --git a/cli/start.js b/cli/start.js new file mode 100755 index 0000000..7a6eeb8 --- /dev/null +++ b/cli/start.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { printAndExitLoadConfigError } from '@platformatic/config' +import { start } from '@platformatic/service' +import { stackable } from '../lib/index.js' + +start(stackable, process.argv.splice(2)).catch(printAndExitLoadConfigError) diff --git a/config.d.ts b/config.d.ts new file mode 100644 index 0000000..50aaab5 --- /dev/null +++ b/config.d.ts @@ -0,0 +1,875 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface PlatformaticPythonConfiguration { + basePath?: string; + server?: { + hostname?: string; + port?: number | string; + pluginTimeout?: number; + healthCheck?: + | boolean + | { + enabled?: boolean; + interval?: number; + [k: string]: unknown; + }; + ignoreTrailingSlash?: boolean; + ignoreDuplicateSlashes?: boolean; + connectionTimeout?: number; + keepAliveTimeout?: number; + maxRequestsPerSocket?: number; + forceCloseConnections?: boolean | string; + requestTimeout?: number; + bodyLimit?: number; + maxParamLength?: number; + disableRequestLogging?: boolean; + exposeHeadRoutes?: boolean; + logger?: + | boolean + | { + level: ( + | ("fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent") + | { + [k: string]: unknown; + } + ) & + string; + transport?: + | { + target?: string; + options?: { + [k: string]: unknown; + }; + } + | { + targets?: { + target?: string; + options?: { + [k: string]: unknown; + }; + level?: string; + }[]; + options?: { + [k: string]: unknown; + }; + }; + pipeline?: { + target?: string; + options?: { + [k: string]: unknown; + }; + }; + formatters?: { + path: string; + }; + timestamp?: "epochTime" | "unixTime" | "nullTime" | "isoTime"; + redact?: { + paths: string[]; + censor?: string; + }; + base?: { + [k: string]: unknown; + } | null; + messageKey?: string; + customLevels?: { + [k: string]: unknown; + }; + [k: string]: unknown; + }; + loggerInstance?: { + [k: string]: unknown; + }; + serializerOpts?: { + schema?: { + [k: string]: unknown; + }; + ajv?: { + [k: string]: unknown; + }; + rounding?: "floor" | "ceil" | "round" | "trunc"; + debugMode?: boolean; + mode?: "debug" | "standalone"; + largeArraySize?: number | string; + largeArrayMechanism?: "default" | "json-stringify"; + [k: string]: unknown; + }; + caseSensitive?: boolean; + requestIdHeader?: string | false; + requestIdLogLabel?: string; + jsonShorthand?: boolean; + trustProxy?: boolean | string | string[] | number; + http2?: boolean; + https?: { + allowHTTP1?: boolean; + key: + | string + | { + path?: string; + } + | ( + | string + | { + path?: string; + } + )[]; + cert: + | string + | { + path?: string; + } + | ( + | string + | { + path?: string; + } + )[]; + requestCert?: boolean; + rejectUnauthorized?: boolean; + }; + cors?: { + origin?: + | boolean + | string + | ( + | string + | { + regexp: string; + [k: string]: unknown; + } + )[] + | { + regexp: string; + [k: string]: unknown; + }; + methods?: string[]; + /** + * Comma separated string of allowed headers. + */ + allowedHeaders?: string; + exposedHeaders?: string[] | string; + credentials?: boolean; + maxAge?: number; + preflightContinue?: boolean; + optionsSuccessStatus?: number; + preflight?: boolean; + strictPreflight?: boolean; + hideOptionsRoute?: boolean; + }; + }; + plugins?: { + [k: string]: unknown; + }; + metrics?: + | boolean + | { + port?: number | string; + hostname?: string; + endpoint?: string; + server?: "own" | "parent" | "hide"; + defaultMetrics?: { + enabled: boolean; + }; + auth?: { + username: string; + password: string; + }; + labels?: { + [k: string]: string; + }; + }; + telemetry?: { + enabled?: boolean | string; + /** + * The name of the service. Defaults to the folder name if not specified. + */ + serviceName: string; + /** + * The version of the service (optional) + */ + version?: string; + /** + * An array of paths to skip when creating spans. Useful for health checks and other endpoints that do not need to be traced. + */ + skip?: { + /** + * The path to skip. Can be a string or a regex. + */ + path?: string; + /** + * HTTP method to skip + */ + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; + [k: string]: unknown; + }[]; + exporter?: + | { + type?: "console" | "otlp" | "zipkin" | "memory" | "file"; + /** + * Options for the exporter. These are passed directly to the exporter. + */ + options?: { + /** + * The URL to send the traces to. Not used for console or memory exporters. + */ + url?: string; + /** + * Headers to send to the exporter. Not used for console or memory exporters. + */ + headers?: { + [k: string]: unknown; + }; + /** + * The path to write the traces to. Only for file exporter. + */ + path?: string; + [k: string]: unknown; + }; + additionalProperties?: never; + [k: string]: unknown; + }[] + | { + type?: "console" | "otlp" | "zipkin" | "memory" | "file"; + /** + * Options for the exporter. These are passed directly to the exporter. + */ + options?: { + /** + * The URL to send the traces to. Not used for console or memory exporters. + */ + url?: string; + /** + * Headers to send to the exporter. Not used for console or memory exporters. + */ + headers?: { + [k: string]: unknown; + }; + /** + * The path to write the traces to. Only for file exporter. + */ + path?: string; + [k: string]: unknown; + }; + additionalProperties?: never; + [k: string]: unknown; + }; + }; + watch?: + | { + enabled?: boolean | string; + /** + * @minItems 1 + */ + allow?: [string, ...string[]]; + ignore?: string[]; + } + | boolean + | string; + $schema?: string; + module?: string; + service?: { + openapi?: + | { + info?: Info; + jsonSchemaDialect?: string; + servers?: Server[]; + paths?: Paths; + webhooks?: { + [k: string]: PathItemOrReference; + }; + components?: Components; + security?: SecurityRequirement[]; + tags?: Tag[]; + externalDocs?: ExternalDocumentation; + /** + * Base URL for the OpenAPI Swagger Documentation + */ + swaggerPrefix?: string; + /** + * Path to an OpenAPI spec file + */ + path?: string; + } + | boolean; + graphql?: + | { + graphiql?: boolean; + } + | boolean; + proxy?: + | false + | { + upstream?: string; + prefix?: string; + hostname?: string; + ws?: { + upstream?: string; + reconnect?: { + pingInterval?: number; + maxReconnectionRetries?: number; + reconnectInterval?: number; + reconnectDecay?: number; + connectionTimeout?: number; + reconnectOnClose?: boolean; + logs?: boolean; + [k: string]: unknown; + }; + hooks?: { + path: string; + }; + }; + }; + }; + clients?: { + serviceId?: string; + name?: string; + type?: "openapi" | "graphql"; + path?: string; + schema?: string; + url?: string; + fullResponse?: boolean; + fullRequest?: boolean; + validateResponse?: boolean; + }[]; + runtime?: { + preload?: string | string[]; + basePath?: string; + workers?: number | string; + logger?: { + level: ( + | ("fatal" | "error" | "warn" | "info" | "debug" | "trace" | "silent") + | { + [k: string]: unknown; + } + ) & + string; + transport?: + | { + target?: string; + options?: { + [k: string]: unknown; + }; + } + | { + targets?: { + target?: string; + options?: { + [k: string]: unknown; + }; + level?: string; + }[]; + options?: { + [k: string]: unknown; + }; + }; + pipeline?: { + target?: string; + options?: { + [k: string]: unknown; + }; + }; + formatters?: { + path: string; + }; + timestamp?: "epochTime" | "unixTime" | "nullTime" | "isoTime"; + redact?: { + paths: string[]; + censor?: string; + }; + base?: { + [k: string]: unknown; + } | null; + messageKey?: string; + customLevels?: { + [k: string]: unknown; + }; + [k: string]: unknown; + }; + server?: { + hostname?: string; + port?: number | string; + http2?: boolean; + https?: { + allowHTTP1?: boolean; + key: + | string + | { + path?: string; + } + | ( + | string + | { + path?: string; + } + )[]; + cert: + | string + | { + path?: string; + } + | ( + | string + | { + path?: string; + } + )[]; + requestCert?: boolean; + rejectUnauthorized?: boolean; + }; + }; + startTimeout?: number; + restartOnError?: boolean | number; + gracefulShutdown?: { + runtime: number | string; + service: number | string; + }; + health?: { + enabled?: boolean | string; + interval?: number | string; + gracePeriod?: number | string; + maxUnhealthyChecks?: number | string; + maxELU?: number | string; + maxHeapUsed?: number | string; + maxHeapTotal?: number | string; + maxYoungGeneration?: number; + }; + undici?: { + agentOptions?: { + [k: string]: unknown; + }; + interceptors?: + | { + module: string; + options: { + [k: string]: unknown; + }; + [k: string]: unknown; + }[] + | { + Client?: { + module: string; + options: { + [k: string]: unknown; + }; + [k: string]: unknown; + }[]; + Pool?: { + module: string; + options: { + [k: string]: unknown; + }; + [k: string]: unknown; + }[]; + Agent?: { + module: string; + options: { + [k: string]: unknown; + }; + [k: string]: unknown; + }[]; + [k: string]: unknown; + }; + [k: string]: unknown; + }; + httpCache?: + | boolean + | { + store?: string; + /** + * @minItems 1 + */ + methods?: [string, ...string[]]; + cacheTagsHeader?: string; + maxSize?: number; + maxEntrySize?: number; + maxCount?: number; + [k: string]: unknown; + }; + watch?: boolean | string; + managementApi?: + | boolean + | string + | { + logs?: { + maxSize?: number; + }; + }; + metrics?: + | boolean + | { + port?: number | string; + enabled?: boolean | string; + hostname?: string; + endpoint?: string; + auth?: { + username: string; + password: string; + }; + labels?: { + [k: string]: string; + }; + readiness?: + | boolean + | { + endpoint?: string; + success?: { + statusCode?: number; + body?: string; + }; + fail?: { + statusCode?: number; + body?: string; + }; + }; + liveness?: + | boolean + | { + endpoint?: string; + success?: { + statusCode?: number; + body?: string; + }; + fail?: { + statusCode?: number; + body?: string; + }; + }; + additionalProperties?: never; + [k: string]: unknown; + }; + telemetry?: { + enabled?: boolean | string; + /** + * The name of the service. Defaults to the folder name if not specified. + */ + serviceName: string; + /** + * The version of the service (optional) + */ + version?: string; + /** + * An array of paths to skip when creating spans. Useful for health checks and other endpoints that do not need to be traced. + */ + skip?: { + /** + * The path to skip. Can be a string or a regex. + */ + path?: string; + /** + * HTTP method to skip + */ + method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS"; + [k: string]: unknown; + }[]; + exporter?: + | { + type?: "console" | "otlp" | "zipkin" | "memory" | "file"; + /** + * Options for the exporter. These are passed directly to the exporter. + */ + options?: { + /** + * The URL to send the traces to. Not used for console or memory exporters. + */ + url?: string; + /** + * Headers to send to the exporter. Not used for console or memory exporters. + */ + headers?: { + [k: string]: unknown; + }; + /** + * The path to write the traces to. Only for file exporter. + */ + path?: string; + [k: string]: unknown; + }; + additionalProperties?: never; + [k: string]: unknown; + }[] + | { + type?: "console" | "otlp" | "zipkin" | "memory" | "file"; + /** + * Options for the exporter. These are passed directly to the exporter. + */ + options?: { + /** + * The URL to send the traces to. Not used for console or memory exporters. + */ + url?: string; + /** + * Headers to send to the exporter. Not used for console or memory exporters. + */ + headers?: { + [k: string]: unknown; + }; + /** + * The path to write the traces to. Only for file exporter. + */ + path?: string; + [k: string]: unknown; + }; + additionalProperties?: never; + [k: string]: unknown; + }; + }; + inspectorOptions?: { + host?: string; + port?: number; + breakFirstLine?: boolean; + watchDisabled?: boolean; + [k: string]: unknown; + }; + serviceTimeout?: number | string; + env?: { + [k: string]: string; + }; + sourceMaps?: boolean; + scheduler?: { + enabled?: boolean | string; + name: string; + cron: string; + callbackUrl: string; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: { + [k: string]: string; + }; + body?: + | string + | { + [k: string]: unknown; + }; + maxRetries?: number; + [k: string]: unknown; + }[]; + }; + python?: { + /** + * Path to the root of the Python project + */ + docroot: string; + /** + * The Python module and function to load (format: module:function) + */ + appTarget?: string; + [k: string]: unknown; + }; +} +export interface Info { + title: string; + summary?: string; + description?: string; + termsOfService?: string; + contact?: Contact; + license?: License; + version: string; + /** + * This interface was referenced by `Info`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface Contact { + name?: string; + url?: string; + email?: string; + /** + * This interface was referenced by `Contact`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface License { + name: string; + identifier?: string; + url?: string; + /** + * This interface was referenced by `License`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface Server { + url: string; + description?: string; + variables?: { + [k: string]: ServerVariable; + }; + /** + * This interface was referenced by `Server`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface ServerVariable { + /** + * @minItems 1 + */ + enum?: [string, ...string[]]; + default: string; + description?: string; + /** + * This interface was referenced by `ServerVariable`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface Paths { + [k: string]: PathItem; +} +/** + * This interface was referenced by `Paths`'s JSON-Schema definition + * via the `patternProperty` "^/". + */ +export interface PathItem { + summary?: string; + description?: string; + servers?: Server[]; + parameters?: ParameterOrReference[]; + get?: Operation; + put?: Operation1; + post?: Operation1; + delete?: Operation1; + options?: Operation1; + head?: Operation1; + patch?: Operation1; + trace?: Operation1; + /** + * This interface was referenced by `PathItem`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface ParameterOrReference { + [k: string]: unknown; +} +export interface Operation { + tags?: string[]; + summary?: string; + description?: string; + externalDocs?: ExternalDocumentation; + operationId?: string; + parameters?: ParameterOrReference[]; + requestBody?: RequestBodyOrReference; + responses?: Responses; + callbacks?: { + [k: string]: CallbacksOrReference; + }; + security?: SecurityRequirement[]; + servers?: Server[]; + /** + * This interface was referenced by `Operation`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface ExternalDocumentation { + description?: string; + url: string; + /** + * This interface was referenced by `ExternalDocumentation`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface RequestBodyOrReference { + [k: string]: unknown; +} +export interface Responses { + [k: string]: ResponseOrReference; +} +export interface ResponseOrReference { + [k: string]: unknown; +} +export interface CallbacksOrReference { + [k: string]: unknown; +} +export interface SecurityRequirement { + [k: string]: string[]; +} +export interface Operation1 { + tags?: string[]; + summary?: string; + description?: string; + externalDocs?: ExternalDocumentation; + operationId?: string; + parameters?: ParameterOrReference[]; + requestBody?: RequestBodyOrReference; + responses?: Responses; + callbacks?: { + [k: string]: CallbacksOrReference; + }; + security?: SecurityRequirement[]; + servers?: Server[]; + /** + * This interface was referenced by `Operation1`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface PathItemOrReference { + [k: string]: unknown; +} +export interface Components { + schemas?: { + [k: string]: unknown; + }; + responses?: { + [k: string]: ResponseOrReference; + }; + parameters?: { + [k: string]: ParameterOrReference; + }; + examples?: { + [k: string]: ExampleOrReference; + }; + requestBodies?: { + [k: string]: RequestBodyOrReference; + }; + headers?: { + [k: string]: HeaderOrReference; + }; + securitySchemes?: { + [k: string]: SecuritySchemeOrReference; + }; + links?: { + [k: string]: LinkOrReference; + }; + callbacks?: { + [k: string]: CallbacksOrReference; + }; + pathItems?: { + [k: string]: PathItemOrReference; + }; + /** + * This interface was referenced by `Components`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} +export interface ExampleOrReference { + [k: string]: unknown; +} +export interface HeaderOrReference { + [k: string]: unknown; +} +export interface SecuritySchemeOrReference { + [k: string]: unknown; +} +export interface LinkOrReference { + [k: string]: unknown; +} +export interface Tag { + name: string; + description?: string; + externalDocs?: ExternalDocumentation; + /** + * This interface was referenced by `Tag`'s JSON-Schema definition + * via the `patternProperty` "^x-". + */ + [k: string]: unknown; +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..fc6bef1 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,5 @@ +import neostandard from 'neostandard' + +export default neostandard({ + ignores: ['node_modules', 'dist', 'wordpress', 'plt-python'], +}) diff --git a/lib/generator.js b/lib/generator.js new file mode 100644 index 0000000..069af67 --- /dev/null +++ b/lib/generator.js @@ -0,0 +1,78 @@ +import { Generator as ServiceGenerator } from '@platformatic/service' +import { readFile } from 'node:fs/promises' +import { basename, resolve, join } from 'node:path' + +export class Generator extends ServiceGenerator { + constructor (opts = {}) { + super({ + ...opts, + module: '@platformatic/python' + }) + } + + getDefaultConfig () { + const res = { + ...super.getDefaultConfig(), + plugin: false, + tests: false + } + + return res + } + + async generatePackageJson () { + const template = await super.generatePackageJson() + + template.devDependencies = undefined + template.scripts.test = 'echo "No tests defined".' + template.engines.node = '>= 22.18.0' + + return template + } + + async _getConfigFileContents () { + const packageJson = await this._getStackablePackageJson() + const { server, watch } = await super._getConfigFileContents() + + return { + $schema: `https://schemas.platformatic.dev/@platformatic/python/${packageJson.version}.json`, + module: `${packageJson.name}`, + python: { + docroot: 'public', + appTarget: 'main:app' + }, + server, + watch + } + } + + async _beforePrepare () { + super._beforePrepare() + + delete this.config.env.PLT_TYPESCRIPT + delete this.config.defaultEnv.PLT_TYPESCRIPT + + const packageJson = await this._getStackablePackageJson() + + this.config.dependencies = { + [packageJson.name]: `^${packageJson.version}` + } + } + + async _afterPrepare () { + delete this.files['global.d.ts'] + delete this.files['.gitignore'] + + if (!this.config.isUpdating) { + this.addFile({ path: 'public', file: 'main.py', contents: await readFile(join(import.meta.dirname, 'main.py'), 'utf-8') }) + } + } + + async _getStackablePackageJson () { + if (!this._packageJson) { + this._packageJson = JSON.parse(await readFile(resolve(import.meta.dirname, '../package.json'), 'utf-8')) + } + + return this._packageJson + } +} diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..55fd4d5 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,33 @@ +import { buildStackable } from '@platformatic/service' +import { Generator as _Generator } from './generator.js' +import { plugin } from './plugin.js' +import { packageJson, schema } from './schema.js' + +export async function stackable (fastify, opts) { + await fastify.register(plugin, opts) +} + +stackable.Generator = _Generator +stackable.configType = 'python' +stackable.schema = schema +stackable.configManagerConfig = { + schemaOptions: { + useDefaults: true, + coerceTypes: true, + allErrors: true, + strict: false + } +} + +export const Generator = _Generator + +export default { + configType: 'python', + configManagerConfig: stackable.configManagerConfig, + /* c8 ignore next 3 */ + async buildStackable (opts) { + return buildStackable(opts, stackable) + }, + schema, + version: packageJson.version +} diff --git a/lib/main.py b/lib/main.py new file mode 100644 index 0000000..a820a7b --- /dev/null +++ b/lib/main.py @@ -0,0 +1,86 @@ +import platform +import sys +import os + +async def app(scope, receive, send): + """ + A simple ASGI application that displays Python environment information + """ + if scope["type"] == "http": + body = f""" + + + Python Information + + + +
+
+

🐍 Python ASGI Application

+
+
+

Python Version

+

Version: {sys.version}

+

Platform: {platform.platform()}

+

Architecture: {platform.machine()}

+
+
+ +
+
+

Environment

+
+
+

Python Executable: {sys.executable}

+

Current Working Directory: {os.getcwd()}

+

Python Path:

+
    + {''.join(f'
  • {path}
  • ' for path in sys.path[:5])} + {'
  • ... and more
  • ' if len(sys.path) > 5 else ''} +
+
+
+ +
+
+

Request Information

+
+
+

Method: {scope.get('method', 'N/A')}

+

Path: {scope.get('path', 'N/A')}

+

Query String: {scope.get('query_string', b'').decode('utf-8') or 'None'}

+
+
+ +""" + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'text/html; charset=utf-8'], + ], + }) + + await send({ + 'type': 'http.response.body', + 'body': body.encode('utf-8'), + }) + else: + # Handle non-HTTP requests (WebSocket, etc.) + await send({ + 'type': 'http.response.start', + 'status': 404, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b'Not Found', + }) diff --git a/lib/plugin.js b/lib/plugin.js new file mode 100644 index 0000000..70f1de5 --- /dev/null +++ b/lib/plugin.js @@ -0,0 +1,111 @@ +import { readFile } from 'node:fs/promises' +import { basename } from 'node:path' + +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'TRACE'] + +const capitalizeHeaders = header => header.replace(/(^|-)([a-z])/g, (_, dash, letter) => dash + letter.toUpperCase()); + +export async function plugin (server, opts) { + // We import this dynically to provide better error reporting in case + // this module fails to load one of the native bindings + const { Python, Request } = await import('@platformatic/python-node') + + /* c8 ignore next */ + const configuration = server.platformatic?.config ?? opts.context?.stackable.configManager.current + const docroot = configuration.python.docroot + const appTarget = configuration.python.appTarget + + // Register Python routes first, before static files + for (const method of HTTP_METHODS) { + server.route({ + method, + url: '/*', + handler: async (req, reply) => { + const url = urlForRequest(req) + + // Python needs capitalized headers + const headers = {} + for (const key of Object.keys(req.headers)) { + const actual = capitalizeHeaders(key) + + if (Array.isArray(headers[actual])) { + headers[actual].push(req.headers[key]) + } else { + headers[actual] = [req.headers[key]] + } + } + + const reqInput = { + method: req.method, + url: url.href, + headers, + body: req.body, + } + + const pythonReq = new Request(reqInput) + + try { + const pythonRes = await python.handleRequest(pythonReq) + + if (pythonRes.log.length) { + req.log.info(pythonRes.log.toString()) + } + + if (pythonRes.exception) { + req.log.warn({ pythonError: pythonRes.exception.toString() }, 'Python error') + } + + reply.status(pythonRes.status) + for (const [key, value] of pythonRes.headers.entries()) { + reply.header(key, value) + } + reply.send(pythonRes.body) + } catch (error) { + // If Python can't handle it, try static files + if (error.message.indexOf('No response sent') !== -1 || error.message.indexOf('not found') !== -1) { + reply.callNotFound() + return + } + reply.status(500) + reply.send(error.message) + } + + return reply + } + }) + } + + // All files in the docroot that are not Python files, should be served as static files + await server.register(import('@fastify/static'), { + root: docroot, + wildcard: false, + // TODO(mcollina): make this configurable + globIgnore: ['**/*.py', '**/*.pyc', '__pycache__/**', 'node_modules/**'] + }) + + // We accept all content-types and parse them as buffer, so that Python can + // handle them + server.addContentTypeParser(/^.*/, { parseAs: 'buffer' }, (request, body, done) => { + done(null, body) + }) + + const python = new Python({ + docroot, + appTarget + }) +} + +// A full URL string is needed for Python, but Node.js splits that across a bunch of places. +function urlForRequest(req) { + const proto = req.raw.protocol ?? 'http:' + const host = req.headers.host ?? 'localhost' + return new URL(req.url, `${proto}//${host}`) +} + +// Currently header values must be arrays. Need to make it support single values too. +function fixHeaders (headers) { + return Object.fromEntries( + Object.entries(headers) + .map(([key, value]) => [key, [value]]) + ) +} diff --git a/lib/schema.js b/lib/schema.js new file mode 100644 index 0000000..1ad6f1f --- /dev/null +++ b/lib/schema.js @@ -0,0 +1,33 @@ +import { schema as serviceSchema } from '@platformatic/service' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +export const packageJson = JSON.parse(readFileSync(resolve(import.meta.dirname, '../package.json'), 'utf-8')) + +export const schema = { + $id: `https://schemas.platformatic.dev/@platformatic/python/${packageJson.version}.json`, + title: 'Platformatic Python configuration', + version: packageJson.version, + type: 'object', + properties: { + ...serviceSchema.properties, + python: { + type: 'object', + properties: { + docroot: { + type: 'string', + description: 'Path to the root of the Python project', + resolvePath: true + }, + appTarget: { + type: 'string', + description: 'The Python module and function to load (format: module:function)', + default: 'main:app' + } + }, + required: ['docroot'], + } + }, + additionalProperties: false, + $defs: serviceSchema.$defs +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..86df75e --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "@platformatic/python", + "version": "0.7.0", + "description": "Integration of Python with Wattpm", + "scripts": { + "test": "node --test", + "build": "node -e 'import {schema} from \"./lib/schema.js\"; console.log(JSON.stringify(schema, null, 2))' > schema.json && json2ts > config.d.ts < schema.json", + "ci": "npm run lint && npm run test:ci" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/platformatic/python.git" + }, + "keywords": [ + "wattpm", + "thread", + "python" + ], + "type": "module", + "main": "./lib/index.js", + "bin": { + "create-platformatic-python": "./cli/create.js", + "start-platformatic-python": "./cli/start.js" + }, + "author": "Platformatic Inc. (https://platformatic.dev)", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/platformatic/python/issues" + }, + "homepage": "https://github.com/platformatic/python#readme", + "dependencies": { + "@fastify/static": "^8.2.0", + "@platformatic/python-node": "github:platformatic/python-node#test-improvements", + "@platformatic/service": "^2.63.3", + "json-schema-to-typescript": "^15.0.4" + }, + "devDependencies": { + "@platformatic/utils": "^2.64.0", + "c8": "^10.1.3", + "eslint": "^9.26.0", + "form-auto-content": "^3.2.1", + "neostandard": "^0.12.1", + "platformatic": "^2.63.3" + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..08b5f0c --- /dev/null +++ b/renovate.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "rangeStrategy": "update-lockfile", + "prHourlyLimit": 1, + "packageRules": [ + { + "matchUpdateTypes": [ + "minor", + "patch", + "pin", + "digest" + ], + "automerge": true + } + ], + "lockFileMaintenance": { + "enabled": true, + "automerge": true + }, + "timezone": "Europe/Rome", + "schedule": [ + "* 0-6 * * 6,0" + ], + "rebaseWhen": "conflicted" +} diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..4c1dfb4 --- /dev/null +++ b/schema.json @@ -0,0 +1,3263 @@ +{ + "$id": "https://schemas.platformatic.dev/@platformatic/python/0.7.0.json", + "title": "Platformatic Python configuration", + "version": "0.7.0", + "type": "object", + "properties": { + "basePath": { + "type": "string" + }, + "server": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "pluginTimeout": { + "type": "integer" + }, + "healthCheck": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "interval": { + "type": "integer" + } + }, + "additionalProperties": true + } + ] + }, + "ignoreTrailingSlash": { + "type": "boolean" + }, + "ignoreDuplicateSlashes": { + "type": "boolean" + }, + "connectionTimeout": { + "type": "integer" + }, + "keepAliveTimeout": { + "type": "integer", + "default": 5000 + }, + "maxRequestsPerSocket": { + "type": "integer" + }, + "forceCloseConnections": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^idle$" + } + ] + }, + "requestTimeout": { + "type": "integer" + }, + "bodyLimit": { + "type": "integer" + }, + "maxParamLength": { + "type": "integer" + }, + "disableRequestLogging": { + "type": "boolean" + }, + "exposeHeadRoutes": { + "type": "boolean" + }, + "logger": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "level": { + "type": "string", + "default": "info", + "oneOf": [ + { + "enum": [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "silent" + ] + }, + { + "pattern": "^\\{.+\\}$" + } + ] + }, + "transport": { + "anyOf": [ + { + "type": "object", + "properties": { + "target": { + "type": "string", + "resolveModule": true + }, + "options": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "targets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "string", + "resolveModule": true + }, + { + "type": "string", + "resolvePath": true + } + ] + }, + "options": { + "type": "object" + }, + "level": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "options": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "pipeline": { + "type": "object", + "properties": { + "target": { + "type": "string", + "resolveModule": true + }, + "options": { + "type": "object" + } + }, + "additionalProperties": false + }, + "formatters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "timestamp": { + "enum": [ + "epochTime", + "unixTime", + "nullTime", + "isoTime" + ] + }, + "redact": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "censor": { + "type": "string", + "default": "[redacted]" + } + }, + "required": [ + "paths" + ], + "additionalProperties": false + }, + "base": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "messageKey": { + "type": "string" + }, + "customLevels": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "level" + ], + "default": {}, + "additionalProperties": true + } + ] + }, + "loggerInstance": { + "type": "object" + }, + "serializerOpts": { + "type": "object", + "properties": { + "schema": { + "type": "object" + }, + "ajv": { + "type": "object" + }, + "rounding": { + "type": "string", + "enum": [ + "floor", + "ceil", + "round", + "trunc" + ], + "default": "trunc" + }, + "debugMode": { + "type": "boolean" + }, + "mode": { + "type": "string", + "enum": [ + "debug", + "standalone" + ] + }, + "largeArraySize": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ], + "default": 20000 + }, + "largeArrayMechanism": { + "type": "string", + "enum": [ + "default", + "json-stringify" + ], + "default": "default" + } + } + }, + "caseSensitive": { + "type": "boolean" + }, + "requestIdHeader": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean", + "const": false + } + ] + }, + "requestIdLogLabel": { + "type": "string" + }, + "jsonShorthand": { + "type": "boolean" + }, + "trustProxy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "integer" + } + ] + }, + "http2": { + "type": "boolean" + }, + "https": { + "type": "object", + "properties": { + "allowHTTP1": { + "type": "boolean" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "cert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "requestCert": { + "type": "boolean" + }, + "rejectUnauthorized": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "key", + "cert" + ] + }, + "cors": { + "type": "object", + "$comment": "See https://github.com/fastify/fastify-cors", + "properties": { + "origin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "regexp": { + "type": "string" + } + }, + "required": [ + "regexp" + ] + } + ] + } + }, + { + "type": "object", + "properties": { + "regexp": { + "type": "string" + } + }, + "required": [ + "regexp" + ] + } + ] + }, + "methods": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowedHeaders": { + "type": "string", + "description": "Comma separated string of allowed headers." + }, + "exposedHeaders": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "description": "Comma separated string of exposed headers." + } + ] + }, + "credentials": { + "type": "boolean" + }, + "maxAge": { + "type": "integer" + }, + "preflightContinue": { + "type": "boolean", + "default": false + }, + "optionsSuccessStatus": { + "type": "integer", + "default": 204 + }, + "preflight": { + "type": "boolean", + "default": true + }, + "strictPreflight": { + "type": "boolean", + "default": true + }, + "hideOptionsRoute": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "plugins": { + "type": "object", + "properties": { + "packages": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "name" + ] + } + ] + } + }, + "paths": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "resolvePath": true + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + }, + "encapsulate": { + "type": "boolean", + "default": true + }, + "maxDepth": { + "type": "integer" + }, + "autoHooks": { + "type": "boolean" + }, + "autoHooksPattern": { + "type": "string" + }, + "cascadeHooks": { + "type": "boolean" + }, + "overwriteHooks": { + "type": "boolean" + }, + "routeParams": { + "type": "boolean" + }, + "forceESM": { + "type": "boolean" + }, + "ignoreFilter": { + "type": "string" + }, + "matchFilter": { + "type": "string" + }, + "ignorePattern": { + "type": "string" + }, + "scriptPattern": { + "type": "string" + }, + "indexPattern": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + }, + "typescript": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "tsConfig": { + "type": "string", + "resolvePath": true + }, + "outDir": { + "type": "string", + "resolvePath": true + }, + "flags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "type": "boolean" + }, + { + "type": "string" + } + ] + } + }, + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "paths" + ] + }, + { + "required": [ + "packages" + ] + } + ] + }, + "metrics": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "hostname": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "server": { + "type": "string", + "enum": [ + "own", + "parent", + "hide" + ] + }, + "defaultMetrics": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "additionalProperties": false + }, + "auth": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "username", + "password" + ] + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "telemetry": { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "serviceName": { + "type": "string", + "description": "The name of the service. Defaults to the folder name if not specified." + }, + "version": { + "type": "string", + "description": "The version of the service (optional)" + }, + "skip": { + "type": "array", + "description": "An array of paths to skip when creating spans. Useful for health checks and other endpoints that do not need to be traced.", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to skip. Can be a string or a regex." + }, + "method": { + "description": "HTTP method to skip", + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS" + ] + } + } + } + }, + "exporter": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "console", + "otlp", + "zipkin", + "memory", + "file" + ], + "default": "console" + }, + "options": { + "type": "object", + "description": "Options for the exporter. These are passed directly to the exporter.", + "properties": { + "url": { + "type": "string", + "description": "The URL to send the traces to. Not used for console or memory exporters." + }, + "headers": { + "type": "object", + "description": "Headers to send to the exporter. Not used for console or memory exporters." + }, + "path": { + "type": "string", + "description": "The path to write the traces to. Only for file exporter." + } + } + }, + "additionalProperties": false + } + } + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "console", + "otlp", + "zipkin", + "memory", + "file" + ], + "default": "console" + }, + "options": { + "type": "object", + "description": "Options for the exporter. These are passed directly to the exporter.", + "properties": { + "url": { + "type": "string", + "description": "The URL to send the traces to. Not used for console or memory exporters." + }, + "headers": { + "type": "object", + "description": "Headers to send to the exporter. Not used for console or memory exporters." + }, + "path": { + "type": "string", + "description": "The path to write the traces to. Only for file exporter." + } + } + }, + "additionalProperties": false + } + } + ] + } + }, + "required": [ + "serviceName" + ], + "additionalProperties": false + }, + "watch": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "default": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "allow": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "nullable": true, + "default": null + }, + "ignore": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true, + "default": null + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "$schema": { + "type": "string" + }, + "module": { + "type": "string" + }, + "service": { + "type": "object", + "properties": { + "openapi": { + "anyOf": [ + { + "type": "object", + "properties": { + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "default": "https://spec.openapis.org/oas/3.1/dialect/base" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [ + { + "url": "/" + } + ] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "components": { + "$ref": "#/$defs/components" + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/$defs/tag" + } + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "swaggerPrefix": { + "type": "string", + "description": "Base URL for the OpenAPI Swagger Documentation" + }, + "path": { + "type": "string", + "description": "Path to an OpenAPI spec file", + "resolvePath": true + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "graphql": { + "anyOf": [ + { + "type": "object", + "properties": { + "graphiql": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + }, + "proxy": { + "anyOf": [ + { + "type": "boolean", + "const": false + }, + { + "type": "object", + "properties": { + "upstream": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "ws": { + "type": "object", + "properties": { + "upstream": { + "type": "string" + }, + "reconnect": { + "type": "object", + "properties": { + "pingInterval": { + "type": "number" + }, + "maxReconnectionRetries": { + "type": "number" + }, + "reconnectInterval": { + "type": "number" + }, + "reconnectDecay": { + "type": "number" + }, + "connectionTimeout": { + "type": "number" + }, + "reconnectOnClose": { + "type": "boolean" + }, + "logs": { + "type": "boolean" + } + } + }, + "hooks": { + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + }, + "clients": { + "type": "array", + "items": { + "type": "object", + "properties": { + "serviceId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "openapi", + "graphql" + ] + }, + "path": { + "type": "string", + "resolvePath": true + }, + "schema": { + "type": "string", + "resolvePath": true + }, + "url": { + "type": "string" + }, + "fullResponse": { + "type": "boolean" + }, + "fullRequest": { + "type": "boolean" + }, + "validateResponse": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "runtime": { + "type": "object", + "properties": { + "preload": { + "anyOf": [ + { + "type": "string", + "resolvePath": true + }, + { + "type": "array", + "items": { + "type": "string", + "resolvePath": true + } + } + ] + }, + "basePath": { + "type": "string" + }, + "workers": { + "anyOf": [ + { + "type": "number", + "minimum": 1 + }, + { + "type": "string" + } + ], + "default": 1 + }, + "logger": { + "type": "object", + "properties": { + "level": { + "type": "string", + "default": "info", + "oneOf": [ + { + "enum": [ + "fatal", + "error", + "warn", + "info", + "debug", + "trace", + "silent" + ] + }, + { + "pattern": "^\\{.+\\}$" + } + ] + }, + "transport": { + "anyOf": [ + { + "type": "object", + "properties": { + "target": { + "type": "string", + "resolveModule": true + }, + "options": { + "type": "object" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "targets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "string", + "resolveModule": true + }, + { + "type": "string", + "resolvePath": true + } + ] + }, + "options": { + "type": "object" + }, + "level": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "options": { + "type": "object" + } + }, + "additionalProperties": false + } + ] + }, + "pipeline": { + "type": "object", + "properties": { + "target": { + "type": "string", + "resolveModule": true + }, + "options": { + "type": "object" + } + }, + "additionalProperties": false + }, + "formatters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "timestamp": { + "enum": [ + "epochTime", + "unixTime", + "nullTime", + "isoTime" + ] + }, + "redact": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "censor": { + "type": "string", + "default": "[redacted]" + } + }, + "required": [ + "paths" + ], + "additionalProperties": false + }, + "base": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "messageKey": { + "type": "string" + }, + "customLevels": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "level" + ], + "default": {}, + "additionalProperties": true + }, + "server": { + "type": "object", + "properties": { + "hostname": { + "type": "string", + "default": "127.0.0.1" + }, + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "http2": { + "type": "boolean" + }, + "https": { + "type": "object", + "properties": { + "allowHTTP1": { + "type": "boolean" + }, + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "cert": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "resolvePath": true + } + }, + "additionalProperties": false + } + ] + } + } + ] + }, + "requestCert": { + "type": "boolean" + }, + "rejectUnauthorized": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "key", + "cert" + ] + } + }, + "additionalProperties": false + }, + "startTimeout": { + "default": 30000, + "type": "number", + "minimum": 0 + }, + "restartOnError": { + "default": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number", + "minimum": 0 + } + ] + }, + "gracefulShutdown": { + "type": "object", + "properties": { + "runtime": { + "anyOf": [ + { + "type": "number", + "minimum": 1 + }, + { + "type": "string" + } + ], + "default": 10000 + }, + "service": { + "anyOf": [ + { + "type": "number", + "minimum": 1 + }, + { + "type": "string" + } + ], + "default": 10000 + } + }, + "default": {}, + "required": [ + "runtime", + "service" + ], + "additionalProperties": false + }, + "health": { + "type": "object", + "default": {}, + "properties": { + "enabled": { + "default": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "interval": { + "default": 30000, + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "gracePeriod": { + "default": 30000, + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "maxUnhealthyChecks": { + "default": 10, + "anyOf": [ + { + "type": "number", + "minimum": 1 + }, + { + "type": "string" + } + ] + }, + "maxELU": { + "default": 0.99, + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "string" + } + ] + }, + "maxHeapUsed": { + "default": 0.99, + "anyOf": [ + { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + { + "type": "string" + } + ] + }, + "maxHeapTotal": { + "default": 4294967296, + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "string" + } + ] + }, + "maxYoungGeneration": { + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "undici": { + "type": "object", + "properties": { + "agentOptions": { + "type": "object", + "additionalProperties": true + }, + "interceptors": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "module", + "options" + ] + } + }, + { + "type": "object", + "properties": { + "Client": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "module", + "options" + ] + } + }, + "Pool": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "module", + "options" + ] + } + }, + "Agent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "module": { + "type": "string" + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "module", + "options" + ] + } + } + } + } + ] + } + } + }, + "httpCache": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "store": { + "type": "string" + }, + "methods": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "GET", + "HEAD" + ], + "minItems": 1 + }, + "cacheTagsHeader": { + "type": "string" + }, + "maxSize": { + "type": "integer" + }, + "maxEntrySize": { + "type": "integer" + }, + "maxCount": { + "type": "integer" + } + } + } + ] + }, + "watch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "managementApi": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "object", + "properties": { + "logs": { + "type": "object", + "properties": { + "maxSize": { + "type": "number", + "minimum": 5, + "default": 200 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ], + "default": true + }, + "metrics": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "port": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "hostname": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "auth": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "username", + "password" + ] + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "readiness": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + }, + "success": { + "type": "object", + "properties": { + "statusCode": { + "type": "number" + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + }, + "fail": { + "type": "object", + "properties": { + "statusCode": { + "type": "number" + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "liveness": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "endpoint": { + "type": "string" + }, + "success": { + "type": "object", + "properties": { + "statusCode": { + "type": "number" + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + }, + "fail": { + "type": "object", + "properties": { + "statusCode": { + "type": "number" + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "additionalProperties": false + } + } + ] + }, + "telemetry": { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "serviceName": { + "type": "string", + "description": "The name of the service. Defaults to the folder name if not specified." + }, + "version": { + "type": "string", + "description": "The version of the service (optional)" + }, + "skip": { + "type": "array", + "description": "An array of paths to skip when creating spans. Useful for health checks and other endpoints that do not need to be traced.", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to skip. Can be a string or a regex." + }, + "method": { + "description": "HTTP method to skip", + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS" + ] + } + } + } + }, + "exporter": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "console", + "otlp", + "zipkin", + "memory", + "file" + ], + "default": "console" + }, + "options": { + "type": "object", + "description": "Options for the exporter. These are passed directly to the exporter.", + "properties": { + "url": { + "type": "string", + "description": "The URL to send the traces to. Not used for console or memory exporters." + }, + "headers": { + "type": "object", + "description": "Headers to send to the exporter. Not used for console or memory exporters." + }, + "path": { + "type": "string", + "description": "The path to write the traces to. Only for file exporter." + } + } + }, + "additionalProperties": false + } + } + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "console", + "otlp", + "zipkin", + "memory", + "file" + ], + "default": "console" + }, + "options": { + "type": "object", + "description": "Options for the exporter. These are passed directly to the exporter.", + "properties": { + "url": { + "type": "string", + "description": "The URL to send the traces to. Not used for console or memory exporters." + }, + "headers": { + "type": "object", + "description": "Headers to send to the exporter. Not used for console or memory exporters." + }, + "path": { + "type": "string", + "description": "The path to write the traces to. Only for file exporter." + } + } + }, + "additionalProperties": false + } + } + ] + } + }, + "required": [ + "serviceName" + ], + "additionalProperties": false + }, + "inspectorOptions": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "number" + }, + "breakFirstLine": { + "type": "boolean" + }, + "watchDisabled": { + "type": "boolean" + } + } + }, + "serviceTimeout": { + "anyOf": [ + { + "type": "number", + "minimum": 1 + }, + { + "type": "string" + } + ], + "default": 300000 + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "sourceMaps": { + "type": "boolean", + "default": false + }, + "scheduler": { + "type": "array", + "items": { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ], + "default": true + }, + "name": { + "type": "string" + }, + "cron": { + "type": "string" + }, + "callbackUrl": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ], + "default": "GET" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, + "maxRetries": { + "type": "number", + "minimum": 0, + "default": 3 + } + }, + "required": [ + "name", + "cron", + "callbackUrl" + ] + } + } + }, + "additionalProperties": false + }, + "python": { + "type": "object", + "properties": { + "docroot": { + "type": "string", + "description": "Path to the root of the Python project", + "resolvePath": true + }, + "appTarget": { + "type": "string", + "description": "The Python module and function to load (format: module:function)", + "default": "main:app" + } + }, + "required": [ + "docroot" + ] + } + }, + "additionalProperties": false, + "$defs": { + "info": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string" + }, + "contact": { + "$ref": "#/$defs/contact" + }, + "license": { + "$ref": "#/$defs/license" + }, + "version": { + "type": "string" + } + }, + "required": [ + "title", + "version" + ], + "$ref": "#/$defs/specification-extensions" + }, + "contact": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "$ref": "#/$defs/specification-extensions" + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions" + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#server-object", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "description": { + "type": "string" + }, + "variables": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/server-variable" + } + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions" + }, + "server-variable": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#server-variable-object", + "type": "object", + "properties": { + "enum": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "default": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "default" + ], + "$ref": "#/$defs/specification-extensions" + }, + "components": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#components-object", + "type": "object", + "properties": { + "schemas": { + "type": "object" + }, + "responses": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/response-or-reference" + } + }, + "parameters": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + }, + "requestBodies": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/request-body-or-reference" + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "securitySchemes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/security-scheme-or-reference" + } + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "pathItems": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + } + }, + "$ref": "#/$defs/specification-extensions" + }, + "paths": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions" + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#path-item-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "get": { + "$ref": "#/$defs/operation" + }, + "put": { + "$ref": "#/$defs/operation" + }, + "post": { + "$ref": "#/$defs/operation" + }, + "delete": { + "$ref": "#/$defs/operation" + }, + "options": { + "$ref": "#/$defs/operation" + }, + "head": { + "$ref": "#/$defs/operation" + }, + "patch": { + "$ref": "#/$defs/operation" + }, + "trace": { + "$ref": "#/$defs/operation" + } + }, + "$ref": "#/$defs/specification-extensions" + }, + "path-item-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/path-item" + } + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#operation-object", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "requestBody": { + "$ref": "#/$defs/request-body-or-reference" + }, + "responses": { + "$ref": "#/$defs/responses" + }, + "callbacks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/callbacks-or-reference" + } + }, + "security": { + "type": "array", + "items": { + "$ref": "#/$defs/security-requirement" + } + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + } + }, + "$ref": "#/$defs/specification-extensions" + }, + "external-documentation": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions" + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "path", + "cookie" + ] + }, + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "content": { + "type": "object", + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": [ + "name", + "in" + ], + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "if": { + "type": "object", + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "type": "object", + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + }, + "$ref": "#/$defs/specification-extensions" + }, + "parameter-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/parameter" + } + }, + "request-body": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#request-body-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "content": { + "$ref": "#/$defs/content" + }, + "required": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "content" + ], + "$ref": "#/$defs/specification-extensions" + }, + "request-body-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/request-body" + } + }, + "content": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#media-type-object", + "type": "object", + "properties": { + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/examples" + } + ] + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + }, + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/encoding/$defs/explode-default" + } + ], + "$defs": { + "explode-default": { + "if": { + "type": "object", + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "type": "object", + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "type": "object", + "properties": { + "explode": { + "default": false + } + } + } + } + } + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#responses-object", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/response-or-reference" + }, + "minProperties": 1, + "$ref": "#/$defs/specification-extensions" + }, + "response": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#response-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "content": { + "$ref": "#/$defs/content" + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/link-or-reference" + } + } + }, + "required": [ + "description" + ], + "$ref": "#/$defs/specification-extensions" + }, + "response-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/response" + } + }, + "callbacks": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string" + } + }, + "not": { + "required": [ + "value", + "externalValue" + ] + }, + "$ref": "#/$defs/specification-extensions" + }, + "example-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/example" + } + }, + "link": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#link-object", + "type": "object", + "properties": { + "operationRef": { + "type": "string" + }, + "operationId": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/map-of-strings" + }, + "requestBody": true, + "description": { + "type": "string" + }, + "body": { + "$ref": "#/$defs/server" + } + }, + "oneOf": [ + { + "required": [ + "operationRef" + ] + }, + { + "required": [ + "operationId" + ] + } + ], + "$ref": "#/$defs/specification-extensions" + }, + "link-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/link" + } + }, + "header": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#header-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "content": { + "type": "object", + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "oneOf": [ + { + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "$ref": "#/$defs/specification-extensions" + }, + "header-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/header" + } + }, + "tag": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions" + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#reference-object", + "type": "object", + "properties": { + "$ref": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "schema": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#schema-object", + "type": [ + "object", + "boolean" + ] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": [ + "apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect" + ] + }, + "description": { + "type": "string" + } + }, + "required": [ + "type" + ], + "allOf": [ + { + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-apikey" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-http-bearer" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oauth2" + }, + { + "$ref": "#/$defs/security-scheme/$defs/type-oidc" + } + ], + "$defs": { + "type-apikey": { + "if": { + "type": "object", + "properties": { + "type": { + "const": "apiKey" + } + }, + "required": [ + "type" + ] + }, + "then": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "cookie" + ] + } + }, + "required": [ + "name", + "in" + ] + } + }, + "type-http": { + "if": { + "type": "object", + "properties": { + "type": { + "const": "http" + } + }, + "required": [ + "type" + ] + }, + "then": { + "type": "object", + "properties": { + "scheme": { + "type": "string" + } + }, + "required": [ + "scheme" + ] + } + }, + "type-http-bearer": { + "if": { + "type": "object", + "properties": { + "type": { + "const": "http" + }, + "scheme": { + "type": "string", + "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" + } + }, + "required": [ + "type", + "scheme" + ] + }, + "then": { + "type": "object", + "properties": { + "bearerFormat": { + "type": "string" + } + } + } + }, + "type-oauth2": { + "if": { + "type": "object", + "properties": { + "type": { + "const": "oauth2" + } + }, + "required": [ + "type" + ] + }, + "then": { + "type": "object", + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + } + }, + "required": [ + "flows" + ] + } + }, + "type-oidc": { + "if": { + "type": "object", + "properties": { + "type": { + "const": "openIdConnect" + } + }, + "required": [ + "type" + ] + }, + "then": { + "type": "object", + "properties": { + "openIdConnectUrl": { + "type": "string" + } + }, + "required": [ + "openIdConnectUrl" + ] + } + } + } + }, + "security-scheme-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/security-scheme" + } + }, + "oauth-flows": { + "type": "object", + "properties": { + "implicit": { + "$ref": "#/$defs/oauth-flows/$defs/implicit" + }, + "password": { + "$ref": "#/$defs/oauth-flows/$defs/password" + }, + "clientCredentials": { + "$ref": "#/$defs/oauth-flows/$defs/client-credentials" + }, + "authorizationCode": { + "$ref": "#/$defs/oauth-flows/$defs/authorization-code" + } + }, + "$ref": "#/$defs/specification-extensions", + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string" + }, + "refreshUrl": { + "type": "string" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions" + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string" + }, + "refreshUrl": { + "type": "string" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions" + }, + "client-credentials": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string" + }, + "refreshUrl": { + "type": "string" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions" + }, + "authorization-code": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string" + }, + "tokenUrl": { + "type": "string" + }, + "refreshUrl": { + "type": "string" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions" + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#specification-extensions", + "type": "object", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "type": "object", + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } +} diff --git a/test/fixtures/hello/main.py b/test/fixtures/hello/main.py new file mode 100644 index 0000000..42528f5 --- /dev/null +++ b/test/fixtures/hello/main.py @@ -0,0 +1,126 @@ +import json +import urllib.parse + +async def app(scope, receive, send): + """ + ASGI application with routing for different test endpoints + """ + if scope["type"] == "http": + path = scope["path"] + + if path == "/" or path == "/index": + await hello_world(scope, receive, send) + elif path == "/post": + await handle_post(scope, receive, send) + elif path == "/headers": + await handle_headers(scope, receive, send) + else: + # 404 for unknown paths + await send({ + 'type': 'http.response.start', + 'status': 404, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b'Not Found', + }) + else: + await send({ + 'type': 'http.response.start', + 'status': 404, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b'Not Found', + }) + + +async def hello_world(scope, receive, send): + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + + await send({ + 'type': 'http.response.body', + 'body': b'Hello World!', + }) + + +async def handle_post(scope, receive, send): + if scope["method"] != "POST": + await send({ + 'type': 'http.response.start', + 'status': 405, + 'headers': [ + [b'content-type', b'text/plain'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': b'Method Not Allowed', + }) + return + + # Read the body + body = b'' + while True: + message = await receive() + if message['type'] == 'http.request': + body += message.get('body', b'') + if not message.get('more_body', False): + break + + # Parse form data + data = {} + if body: + body_str = body.decode('utf-8') + parsed = urllib.parse.parse_qs(body_str) + for key, value_list in parsed.items(): + data[key] = value_list[0] if len(value_list) == 1 else value_list + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'application/json'], + ], + }) + + await send({ + 'type': 'http.response.body', + 'body': json.dumps(data).encode('utf-8'), + }) + + +async def handle_headers(scope, receive, send): + headers = scope.get("headers", []) + header_dict = {} + + for name, value in headers: + name_str = name.decode('utf-8') if isinstance(name, bytes) else name + value_str = value.decode('utf-8') if isinstance(value, bytes) else value + header_name = f"HTTP_{name_str.upper().replace('-', '_')}" + header_dict[header_name] = value_str + + await send({ + 'type': 'http.response.start', + 'status': 200, + 'headers': [ + [b'content-type', b'application/json'], + ], + }) + + await send({ + 'type': 'http.response.body', + 'body': json.dumps(header_dict, indent=2).encode('utf-8'), + }) diff --git a/test/fixtures/hello/something.txt b/test/fixtures/hello/something.txt new file mode 100644 index 0000000..1f969a5 --- /dev/null +++ b/test/fixtures/hello/something.txt @@ -0,0 +1 @@ +a txt file diff --git a/test/generator.test.js b/test/generator.test.js new file mode 100644 index 0000000..3364dcf --- /dev/null +++ b/test/generator.test.js @@ -0,0 +1,113 @@ +import { safeRemove } from '@platformatic/utils' +import { deepStrictEqual, strictEqual } from 'node:assert' +import { mkdtemp, readdir, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { resolve, join } from 'node:path' +import test from 'node:test' +import { Generator } from '../lib/generator.js' + +test('should return a default Generator config', async () => { + const generator = new Generator() + const defaultConfig = generator.getDefaultConfig() + + strictEqual(defaultConfig.hostname, '0.0.0.0') + strictEqual(defaultConfig.port, 3042) + strictEqual(defaultConfig.plugin, false) + strictEqual(defaultConfig.tests, false) + strictEqual(defaultConfig.typescript, false) + deepStrictEqual(defaultConfig.env, {}) + deepStrictEqual(defaultConfig.dependencies, {}) + deepStrictEqual(defaultConfig.devDependencies, {}) +}) + +test('should return Generator config fields definitions', async () => { + const generator = new Generator() + const configFieldsDefs = generator.getConfigFieldsDefinitions() + + deepStrictEqual(configFieldsDefs, [ + { + var: 'PLT_SERVER_HOSTNAME', + label: 'What is the hostname?', + default: '0.0.0.0', + type: 'string', + configValue: 'hostname' + }, + { + var: 'PLT_SERVER_LOGGER_LEVEL', + label: 'What is the logger level?', + default: 'info', + type: 'string', + configValue: '' + }, + { + label: 'Which port do you want to use?', + var: 'PORT', + default: 3042, + type: 'number', + configValue: 'port' + } + ]) +}) + +test('should generate a stackable app', async t => { + const testDir = await mkdtemp(resolve(tmpdir(), 'stackable-')) + t.after(() => safeRemove(testDir)) + + const generator = new Generator() + + generator.setConfig({ + serviceName: 'stackable-app', + targetDirectory: testDir, + hostname: 'server.example.com', + broker: 'kafka.example.com:9092', + topic: 'topic', + url: 'http://api.example.com', + consumerGroup: 'group' + }) + + await generator.prepare() + await generator.writeFiles() + + { + const files = await readdir(testDir) + deepStrictEqual(files.sort(), ['.env', '.env.sample', '.gitignore', 'package.json', 'platformatic.json', 'public']) + } + + { + const files = await readdir(join(testDir, 'public')) + deepStrictEqual(files.sort(), ['main.py']) + } + + const pythonPackageJson = JSON.parse(await readFile(resolve(import.meta.dirname, '../package.json'), 'utf8')) + const stackablePackageJson = JSON.parse(await readFile(resolve(testDir, 'package.json'), 'utf8')) + deepStrictEqual(stackablePackageJson, { + dependencies: { + '@platformatic/python': `^${pythonPackageJson.version}` + }, + engines: { + node: '>= 22.18.0' + }, + name: 'stackable-app', + scripts: { + start: 'platformatic start', + test: 'echo "No tests defined".' + } + }) + + const stackableConfig = JSON.parse(await readFile(resolve(testDir, 'platformatic.json'), 'utf8')) + + deepStrictEqual(stackableConfig, { + $schema: `https://schemas.platformatic.dev/@platformatic/python/${pythonPackageJson.version}.json`, + module: '@platformatic/python', + python: { + docroot: 'public', + appTarget: 'main:app' + }, + server: { + hostname: '{PLT_SERVER_HOSTNAME}', + port: '{PORT}', + logger: { level: '{PLT_SERVER_LOGGER_LEVEL}' } + }, + watch: true + }) +}) diff --git a/test/plugin.test.js b/test/plugin.test.js new file mode 100644 index 0000000..912c640 --- /dev/null +++ b/test/plugin.test.js @@ -0,0 +1,81 @@ +import { buildServer } from '@platformatic/service' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { resolve, join } from 'node:path' +import { test } from 'node:test' +import { stackable } from '../lib/index.js' +import formAutoContet from 'form-auto-content' + +async function startStackable (t, docroot = join(import.meta.dirname, './fixtures/hello'), opts = {}) { + const config = { + $schema: '../../schema.json', + module: '../../lib/index.js', + python: { + docroot, + appTarget: 'main:app' + }, + port: 0, + server: { + logger: { + level: 'fatal' + } + } + } + + const server = await buildServer(config, stackable) + t.after(async () => { + await server.close() + }) + + return server +} + +test('Python hello world', async t => { + const server = await startStackable(t) + const res = await server.inject('/') + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.body, 'Hello World!') +}) + +test('post data', async t => { + const server = await startStackable(t) + const res = await server.inject({ + url: '/post', + method: 'POST', + ...formAutoContet({ + 'foo': 'bar' + }) + }) + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + foo: 'bar' + }) +}) + +test('get all headers', async t => { + const server = await startStackable(t) + const res = await server.inject('/headers') + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.json(), { + 'HTTP_USER_AGENT': 'lightMyRequest', + 'HTTP_HOST': 'localhost:80' + }) +}) + +test('serve static files in docroot', async t => { + const server = await startStackable(t) + const res = await server.inject('/something.txt') + + t.assert.deepStrictEqual(res.statusCode, 200) + t.assert.deepStrictEqual(res.body, 'a txt file\n') +}) + +test('404', async t => { + const server = await startStackable(t) + const res = await server.inject('/path/to/nowhere') + + t.assert.deepStrictEqual(res.statusCode, 404) +})