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 Version
+
Version: {sys.version}
+
Platform: {platform.platform()}
+
Architecture: {platform.machine()}
+
+
+
+
+
+
+
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 ''}
+
+
+
+
+
+
+
+
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)
+})