diff --git a/.github/workflows/publish-codegen.yml b/.github/workflows/publish-codegen.yml new file mode 100644 index 0000000..cae983e --- /dev/null +++ b/.github/workflows/publish-codegen.yml @@ -0,0 +1,60 @@ +name: Publish codegen + +on: + workflow_dispatch: + inputs: + publish_npm: + type: boolean + default: false + description: Publish @pydantic-socketio/codegen to npm + publish_gh_release: + type: boolean + default: true + description: Create a GitHub Release + use_changelog: + type: boolean + default: true + description: Extract notes from the codegen changelog + release_tag: + type: string + required: false + description: Codegen tag (empty for latest codegen tag) + +jobs: + get-tag: + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.tag.outputs.tag }} + steps: + - name: Verify codegen tag + id: tag + uses: atomiechen/reusable-workflows/.github/actions/verify-tag@main + with: + tag: ${{ inputs.release_tag }} + tag_pattern: "codegen-v*" + + test: + needs: get-tag + uses: ./.github/workflows/test-codegen.yml + with: + release_tag: ${{ needs.get-tag.outputs.release_tag }} + + publish: + needs: [get-tag, test] + permissions: + contents: write + id-token: write + uses: atomiechen/reusable-workflows/.github/workflows/publish-npm-package.yml@main + with: + package_name: "@pydantic-socketio/codegen" + package_dir: packages/codegen + package_manager: npm + tag_prefix: codegen-v + tag_pattern: "codegen-v*" + release_tag: ${{ needs.get-tag.outputs.release_tag }} + changelog_file: packages/codegen/CHANGELOG.md + publish_npm: ${{ inputs.publish_npm }} + publish_gh_release: ${{ inputs.publish_gh_release }} + use_changelog: ${{ inputs.use_changelog }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 643e913..106f346 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,36 +35,19 @@ jobs: outputs: release_tag: ${{ steps.set_release_tag.outputs.tag }} steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Fetch all tags - run: | - git fetch --prune --unshallow --tags - name: Verify and set release tag id: set_release_tag - run: | - release_tag=${{ inputs.release_tag }} - if [ -z "$release_tag" ]; then - echo "Input tag is empty. Fetching latest tag." - release_tag=$(git describe --tags $(git rev-list --tags --max-count=1)) - if [ -z "$release_tag" ]; then - echo "No latest tag available. Exiting workflow." - exit 1 - fi - else - if ! git rev-parse -q --verify "refs/tags/$release_tag" >/dev/null; then - echo "Invalid tag '$release_tag'. Exiting workflow." - exit 1 - fi - fi - echo "tag=$release_tag" >> $GITHUB_OUTPUT + uses: atomiechen/reusable-workflows/.github/actions/verify-tag@main + with: + tag: ${{ inputs.release_tag }} + tag_pattern: "v*" test: needs: get-tag uses: ./.github/workflows/test.yml with: release_tag: ${{ needs.get-tag.outputs.release_tag }} build-n-publish: - needs: test + needs: [get-tag, test] permissions: id-token: write # IMPORTANT: mandatory for trusted publishing and sigstore contents: write # IMPORTANT: mandatory for making GitHub Releases diff --git a/.github/workflows/test-codegen.yml b/.github/workflows/test-codegen.yml new file mode 100644 index 0000000..5389bfd --- /dev/null +++ b/.github/workflows/test-codegen.yml @@ -0,0 +1,77 @@ +name: Test TypeScript codegen + +on: + workflow_call: + inputs: + release_tag: + type: string + required: false + pull_request: + branches: [main] + paths: + - "src/pydantic_socketio/**" + - "packages/codegen/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/test-codegen.yml" + push: + branches: [main] + paths: + - "src/pydantic_socketio/**" + - "packages/codegen/**" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/test-codegen.yml" + +jobs: + consumer-types: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ["18", "20", "22", "24", "26"] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_tag || '' }} + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: packages/codegen/package-lock.json + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - run: uv sync --locked + - run: npm ci --prefix packages/codegen + - run: npm test --prefix packages/codegen + - name: Verify npm tarball installation + run: | + npm pack --dry-run --json ./packages/codegen > "$RUNNER_TEMP/codegen-pack.json" + npm pack ./packages/codegen --pack-destination "$RUNNER_TEMP" + if tar -tzf "$RUNNER_TEMP"/pydantic-socketio-codegen-*.tgz | grep -E '^package/(test/|node_modules/|pyproject\.toml$|src/pydantic_socketio/)' >/dev/null; then + echo 'The npm tarball contains development or Python files.' >&2 + exit 1 + fi + npm install --prefix "$RUNNER_TEMP/codegen-install" "$RUNNER_TEMP"/pydantic-socketio-codegen-*.tgz + "$RUNNER_TEMP/codegen-install/node_modules/.bin/pydantic-socketio-codegen" \ + packages/codegen/test/generated/server.json -o "$RUNNER_TEMP/socketio.generated.ts" + test -s "$RUNNER_TEMP/socketio.generated.ts" + "$RUNNER_TEMP/codegen-install/node_modules/.bin/pydantic-socketio-codegen" \ + packages/codegen/test/generated/client.json -o "$RUNNER_TEMP/client.generated.ts" + test -s "$RUNNER_TEMP/client.generated.ts" + - name: Keep Python distributions free of npm files + if: matrix.node-version == '24' + run: | + uv build --sdist --wheel --out-dir "$RUNNER_TEMP/pyd-sio-dist" + if tar -tzf "$RUNNER_TEMP"/pyd-sio-dist/*.tar.gz | grep '/packages/' >/dev/null; then + echo 'The Python source distribution includes npm packages.' >&2 + exit 1 + fi + if unzip -Z1 "$RUNNER_TEMP"/pyd-sio-dist/*.whl | grep '/packages/' >/dev/null; then + echo 'The Python wheel includes npm packages.' >&2 + exit 1 + fi + uv build "$RUNNER_TEMP"/pyd-sio-dist/*.tar.gz --wheel --out-dir "$RUNNER_TEMP/from-sdist" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4f9220..e17ae15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,14 +30,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Checkout to release tag if provided - if: ${{ inputs.release_tag }} - run: | - echo "Checking out to release tag ${{ inputs.release_tag }}" - git fetch --prune --unshallow --tags - git checkout ${{ inputs.release_tag }} + with: + ref: ${{ inputs.release_tag || '' }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv diff --git a/README.md b/README.md index 94650bd..24db665 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,13 @@ Legacy unscoped emit registrations are shown as applying to all namespaces, with scoped overrides excluded. Lifecycle and catch-all handlers are listed as omitted in the document because they are not concrete events. +### TypeScript codegen + +The in-development [`@pydantic-socketio/codegen`](https://github.com/atomiechen/Pydantic-SocketIO/tree/main/packages/codegen) +package reads that exported JSON and generates event types for the official +`socket.io-client`. Export with `asyncapi_schema()` once, then run the codegen +CLI on the JSON; the Python runtime does not need Node.js. + ### Migration: Monkey Patching Original Socket.IO diff --git a/packages/codegen/.gitignore b/packages/codegen/.gitignore new file mode 100644 index 0000000..842ee7d --- /dev/null +++ b/packages/codegen/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test/generated/ +test/consumer-negative.ts diff --git a/packages/codegen/CHANGELOG.md b/packages/codegen/CHANGELOG.md new file mode 100644 index 0000000..434760a --- /dev/null +++ b/packages/codegen/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to `@pydantic-socketio/codegen` will be recorded here. + +## [Unreleased] + +### Added + +- Generate native Socket.IO TypeScript event types from the Pydantic-SocketIO AsyncAPI contract. diff --git a/packages/codegen/README.md b/packages/codegen/README.md new file mode 100644 index 0000000..8a399ad --- /dev/null +++ b/packages/codegen/README.md @@ -0,0 +1,150 @@ +# TypeScript types for Pydantic-SocketIO + +`@pydantic-socketio/codegen` turns a Pydantic-SocketIO AsyncAPI contract into event types for the official Socket.IO TypeScript client **or server**. Event names, namespace paths, payloads, handler argument names, and ACKs come from the Python registrations. The generated file contains types; it does not replace Socket.IO. + +Requires Pydantic-SocketIO with `asyncapi_schema()` and Node.js 18 or newer. + +## Python server → TypeScript client + +Register both incoming and outgoing events, then export the contract: + +```python +# contract.py +import json +from pathlib import Path + +from pydantic import BaseModel +import pydantic_socketio + + +class Ask(BaseModel): + text: str + + +class Answer(BaseModel): + accepted: bool + + +class Notice(BaseModel): + text: str + + +server = pydantic_socketio.Server(async_mode="threading") +server.register_emit("notice", Notice, namespace="/chat") + + +@server.on("ask", namespace="/chat") +def ask(sid: str, request: Ask) -> Answer: + server.emit("notice", Notice(text=request.text), to=sid, namespace="/chat") + return Answer(accepted=True) + + +schema = pydantic_socketio.asyncapi_schema(server, title="Chat API", version="1.0.0") +Path("asyncapi.json").write_text(json.dumps(schema, indent=2)) +``` + +Run `python contract.py`. In the TypeScript project, generate and use the types: + +```sh +npm install --save-dev @pydantic-socketio/codegen +npx @pydantic-socketio/codegen asyncapi.json -o src/socketio.generated.ts +``` + +```ts +import { io, type Socket } from "socket.io-client"; +import { Chat, type Notice, type Ask, type Answer } from "./socketio.generated"; + +const socket: Socket = io(Chat.path); +// /chat becomes Chat; Chat.path is "/chat". + +socket.on("notice", (notice: Notice) => console.log(notice.text)); +socket.emit("ask", { text: "Hello" } satisfies Ask, (answer: Answer) => { + console.log(answer.accepted); +}); +``` + +The server's `ask` handler supplies `ClientToServerEvents`, its `Answer` return annotation supplies the ACK type, and `register_emit("notice", ...)` supplies `ServerToClientEvents`. + +## Python client → TypeScript server + +The same command works when Python is the client. Its outgoing registrations become `ClientToServerEvents`; its handlers become `ServerToClientEvents`: + +```python +# client_contract.py +import json +from pathlib import Path + +from pydantic import BaseModel +import pydantic_socketio + + +class Ask(BaseModel): + text: str + + +class Answer(BaseModel): + accepted: bool + + +class Notice(BaseModel): + text: str + + +client = pydantic_socketio.Client() +client.register_emit("ask", Ask, namespace="/chat", ack_type=Answer) + + +@client.on("notice", namespace="/chat") +def notice(message: Notice) -> Answer: + print(message.text) + return Answer(accepted=True) + + +schema = pydantic_socketio.asyncapi_schema(client, title="Chat API", version="1.0.0") +Path("asyncapi.json").write_text(json.dumps(schema, indent=2)) +``` + +Run `python client_contract.py`, then run the same `npx @pydantic-socketio/codegen asyncapi.json -o src/socketio.generated.ts` command. On the TypeScript side, use the generated interfaces with the official server: + +```ts +import { Server, type Namespace } from "socket.io"; +import { Chat } from "./socketio.generated"; + +const server = new Server(3000); +const chat: Namespace = + server.of(Chat.path); +chat.on("connection", (socket) => { + socket.on("ask", (request, ack) => { + console.log(request.text); // Ask.text is a string + ack?.({ accepted: true }); // Answer + socket.emit("notice", { text: "Received" }, (answer) => { + console.log(answer.accepted); // boolean + }); + }); +}); +``` + +A Python client can connect to that server and call `ask` with `response_model=Answer` to validate the ACK at runtime. The generated TypeScript describes both directions; no second generator or wrapper is needed. + +## Namespaces and regeneration + +`/` becomes `Root`, `/chat` becomes `Chat`, and `/admin` becomes `Admin`. These are generated TypeScript namespaces containing `path`, `ClientToServerEvents`, and `ServerToClientEvents`; they are not Socket.IO server instances. + +On a TypeScript client, `io(Root.path)` and `io(Chat.path)` return separate namespace sockets, which normally share one underlying connection. On a TypeScript server, use **one** `Server` and obtain namespace handles with `server.of(path)`. If the Python contract covers both `/` and `/chat`, the server can type them separately: + +```ts +import { Server, type Namespace } from "socket.io"; +import { Root, Chat } from "./socketio.generated"; + +const server = new Server(3000); +server.on("connection", (socket) => { /* events in / */ }); +const chat: Namespace = + server.of(Chat.path); +chat.on("connection", (socket) => { /* events in /chat */ }); +``` + +Unscoped outgoing registrations also appear under `Unscoped`; a registration for a specific namespace overrides that fallback there. + +Named Pydantic models are exported as named TypeScript declarations such as `Ask`, `Answer`, and `Notice`. Primitive event arguments stay inline, and Python handler argument names appear in tuple labels. If two different models share a name, codegen adds event context so their types remain distinct. The JSON contract is sufficient to understand the generated file without Python source access. + +After changing event names, models, namespaces, or ACK types, export `asyncapi.json` again, rerun codegen, and type-check the TypeScript project. Use `--help` for CLI syntax. diff --git a/packages/codegen/bin/generate.cjs b/packages/codegen/bin/generate.cjs new file mode 100644 index 0000000..3613ef2 --- /dev/null +++ b/packages/codegen/bin/generate.cjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { generate } = require('../src/generate.cjs'); + +async function main(args) { + if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { + process.stdout.write('Usage: pydantic-socketio-codegen -o \n'); + return; + } + if (args.length !== 3 || !['-o', '--output'].includes(args[1])) { + throw new Error('Expected -o . Use --help for usage.'); + } + const input = path.resolve(args[0]); + const output = path.resolve(args[2]); + if (input === output) throw new Error('Input and output paths must differ'); + let document; + try { + document = JSON.parse(await fs.readFile(input, 'utf8')); + } catch (error) { + throw new Error(`Cannot read AsyncAPI JSON from ${input}: ${error.message}`); + } + const source = await generate(document); + await fs.writeFile(output, source); +} + +main(process.argv.slice(2)).catch((error) => { + process.stderr.write(`pydantic-socketio-codegen: ${error.message}\n`); + process.exitCode = 1; +}); diff --git a/packages/codegen/package-lock.json b/packages/codegen/package-lock.json new file mode 100644 index 0000000..781bccc --- /dev/null +++ b/packages/codegen/package-lock.json @@ -0,0 +1,533 @@ +{ + "name": "@pydantic-socketio/codegen", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pydantic-socketio/codegen", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "json-schema-to-typescript": "16.0.0" + }, + "bin": { + "pydantic-socketio-codegen": "bin/generate.cjs" + }, + "devDependencies": { + "socket.io": "4.8.3", + "socket.io-client": "4.8.3", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/json-schema-to-typescript": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-16.0.0.tgz", + "integrity": "sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.9.3", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.25", + "js-yaml": "^5.2.3", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "prettier": "^3.9.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/packages/codegen/package.json b/packages/codegen/package.json new file mode 100644 index 0000000..03fd4a8 --- /dev/null +++ b/packages/codegen/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pydantic-socketio/codegen", + "version": "0.1.0", + "private": true, + "license": "MIT", + "description": "Generate native Socket.IO TypeScript event types from Pydantic-SocketIO contracts", + "repository": { + "type": "git", + "url": "git+https://github.com/atomiechen/Pydantic-SocketIO.git", + "directory": "packages/codegen" + }, + "bin": { + "pydantic-socketio-codegen": "bin/generate.cjs" + }, + "files": ["bin", "src"], + "engines": {"node": ">=18"}, + "scripts": { + "test": "node test/e2e.cjs && node test/live.cjs" + }, + "dependencies": { + "json-schema-to-typescript": "16.0.0" + }, + "devDependencies": { + "socket.io": "4.8.3", + "socket.io-client": "4.8.3", + "typescript": "5.9.3" + } +} diff --git a/packages/codegen/src/generate.cjs b/packages/codegen/src/generate.cjs new file mode 100644 index 0000000..67b7c52 --- /dev/null +++ b/packages/codegen/src/generate.cjs @@ -0,0 +1,339 @@ +const { compile } = require('json-schema-to-typescript'); + +const EXTENSION = 'x-pydantic-socketio'; + +function pointer(document, reference) { + if (typeof reference !== 'string' || !reference.startsWith('#/')) { + throw new Error(`Expected a local JSON reference, got ${JSON.stringify(reference)}`); + } + const value = reference.slice(2).split('/').reduce((value, part) => { + const key = part.replace(/~1/g, '/').replace(/~0/g, '~'); + if (value === null || typeof value !== 'object' || !(key in value)) { + throw new Error(`Unresolved JSON reference: ${reference}`); + } + return value[key]; + }, document); + if (value && typeof value === 'object' && typeof value.$ref === 'string') { + return pointer(document, value.$ref); + } + return value; +} + +function safeName(text) { + const words = text.normalize('NFKD').replace(/[^A-Za-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean); + const name = words.map((word) => word[0].toUpperCase() + word.slice(1)).join(''); + return !name ? 'Root' : /^[A-Za-z_]/.test(name) ? name : `Namespace${name}`; +} + +function namespaceName(address, used) { + const base = address === '/' ? 'Root' : safeName(address); + let candidate = base; + let suffix = 2; + while (used.has(candidate)) candidate = `${base}${suffix++}`; + used.add(candidate); + return candidate; +} + +function assertDocument(document) { + if (document?.asyncapi !== '3.1.0') { + throw new Error('Expected an AsyncAPI 3.1.0 document'); + } + const extension = document[EXTENSION]; + if (extension?.formatVersion !== 1 || !['server', 'client'].includes(extension.role)) { + throw new Error('Expected x-pydantic-socketio formatVersion 1 and server/client role'); + } + if (!document.operations || !document.channels || !document.components?.schemas) { + throw new Error('Document is missing operations, channels, or component schemas'); + } +} + +function plainType(schema) { + if (!schema || typeof schema !== 'object' || schema.$ref || schema.enum || schema.const !== undefined) return null; + if (Array.isArray(schema.anyOf)) { + const values = schema.anyOf.map(plainType); + return values.every(Boolean) ? values.join(' | ') : null; + } + if (Array.isArray(schema.type)) { + const values = schema.type.map((type) => plainType({ type })); + return values.every(Boolean) ? values.join(' | ') : null; + } + const scalar = { integer: 'number', number: 'number', string: 'string', boolean: 'boolean', null: 'null' }[schema.type]; + if (scalar) return scalar; + if (schema.type === 'array' && schema.items) { + const item = plainType(schema.items); + return item ? `(${item})[]` : null; + } + return null; +} + +function modelRegistry(document) { + const components = document.components.schemas; + const records = []; + const byComponent = new Map(); + const roots = []; + + function fingerprint(schema, seen = new Set()) { + if (Array.isArray(schema)) return schema.map((part) => fingerprint(part, seen)); + if (!schema || typeof schema !== 'object') return schema; + if (schema.$ref) { + const reference = schema.$ref; + if (seen.has(reference)) return { $recursive: pointer(document, reference).title || reference }; + return fingerprint(pointer(document, reference), new Set([...seen, reference])); + } + return Object.fromEntries(Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, fingerprint(value, seen)])); + } + + function add(schema, context, component) { + const title = typeof schema.title === 'string' && schema.title.trim() ? schema.title : null; + const base = safeName(title || context); + const key = `${base}:${JSON.stringify(fingerprint(schema))}`; + const record = { schema, context, component, base, key, name: null }; + records.push(record); + if (component) byComponent.set(component, record); + return record; + } + + for (const [name, schema] of Object.entries(components).sort(([a], [b]) => a.localeCompare(b))) { + add(schema, name, name); + } + function registerRoot(schema, context) { + if (plainType(schema)) return; + if (schema.$ref) return; + roots.push(add(schema, context, null)); + function describeReferences(value, trail, seen = new Set()) { + if (Array.isArray(value)) { + value.forEach((part, index) => describeReferences(part, `${trail}${index + 1}`, seen)); + } else if (value && typeof value === 'object') { + if (value.$ref) { + const component = value.$ref.split('/').at(-1).replace(/~1/g, '/').replace(/~0/g, '~'); + const target = byComponent.get(component); + if (!target) throw new Error(`Unknown schema reference: ${value.$ref}`); + const operationContext = context.replace(/(?:Payload|Ack)Argument\d+$/, ''); + const suggestion = `${safeName(operationContext)}${safeName(trail)}`; + if (target.context === component || suggestion.localeCompare(target.context) < 0) { + target.context = suggestion; + } + if (!seen.has(component)) describeReferences(target.schema, trail, new Set([...seen, component])); + } else { + for (const [key, part] of Object.entries(value)) { + describeReferences(part, key === 'properties' ? trail : `${trail}${safeName(key)}`, seen); + } + } + } + } + describeReferences(schema, ''); + } + function resolve(reserved) { + const groups = new Map(); + for (const record of records) { + if (!groups.has(record.key)) groups.set(record.key, []); + groups.get(record.key).push(record); + } + const unique = [...groups.values()].map((group) => { + group.sort((a, b) => a.context.localeCompare(b.context)); + return { ...group[0], members: group }; + }).sort((a, b) => a.base.localeCompare(b.base) || a.key.localeCompare(b.key)); + const byBase = new Map(); + for (const item of unique) byBase.set(item.base, (byBase.get(item.base) || 0) + 1); + const used = new Set(['PydanticSocketIOModels', ...reserved]); + for (const item of unique) { + const contextName = safeName(item.context.replace(/(?:Payload|Ack)Argument\d+$/, '')); + const candidate = byBase.get(item.base) === 1 + ? (reserved.has(item.base) ? `${item.base}Model` : item.base) + : contextName.endsWith(item.base) ? contextName : `${item.base}${contextName}`; + let name = candidate; + if (used.has(name)) { + const digest = require('node:crypto').createHash('sha256').update(item.key).digest('hex').slice(0, 8); + name = `${candidate}${digest}`; + } + used.add(name); + item.name = name; + for (const member of item.members) member.name = name; + } + return unique; + } + function rewrite(schema) { + if (Array.isArray(schema)) return schema.map(rewrite); + if (!schema || typeof schema !== 'object') return schema; + if (schema.$ref) { + const component = schema.$ref.split('/').at(-1).replace(/~1/g, '/').replace(/~0/g, '~'); + const record = byComponent.get(component); + if (!record) throw new Error(`Unknown schema reference: ${schema.$ref}`); + return { $ref: `#/definitions/${record.name}` }; + } + const result = Object.fromEntries(Object.entries(schema).map(([key, value]) => [key, rewrite(value)])); + if (plainType(result)) delete result.title; + return result; + } + function nameOf(schema, context) { + const scalar = plainType(schema); + if (scalar) return scalar; + if (schema.$ref) { + const component = schema.$ref.split('/').at(-1).replace(/~1/g, '/').replace(/~0/g, '~'); + return byComponent.get(component).name; + } + const root = roots.find((item) => item.schema === schema && item.context === context); + if (!root) throw new Error(`Missing model for ${context}`); + return root.name; + } + async function declarations(unique) { + if (!unique.length) return ''; + const definitions = Object.fromEntries(unique.map((item) => [item.name, { + ...rewrite(item.schema), title: item.name, + }])); + const source = await compile({ + title: 'PydanticSocketIOModels', + anyOf: unique.map((item) => ({ $ref: `#/definitions/${item.name}` })), + definitions, + }, 'PydanticSocketIOModels', { bannerComment: '', additionalProperties: false }); + return source.replace(/^export type PydanticSocketIOModels =[\s\S]*?;\n/, '').trim(); + } + return { registerRoot, resolve, nameOf, declarations }; +} + +async function generate(document) { + assertDocument(document); + const role = document[EXTENSION].role; + const registry = modelRegistry(document); + + function argumentsType(schema, context, names) { + if (Array.isArray(schema?.anyOf)) { + const variants = schema.anyOf.map((variant) => argumentsType(variant, context, names)); + return variants.join(' | '); + } + if (schema?.type !== 'array') throw new Error('Socket.IO arguments must be an array schema'); + if (Array.isArray(schema.prefixItems)) { + if (schema.minItems !== schema.prefixItems.length || schema.maxItems !== schema.prefixItems.length) { + throw new Error('Socket.IO argument tuple has inconsistent arity'); + } + if (names && names.length !== schema.prefixItems.length) { + throw new Error(`${context}: argument names do not match the payload arity`); + } + const items = schema.prefixItems.map((item, index) => { + const name = names?.[index]?.name; + const type = registry.nameOf(item, `${context}Argument${index + 1}`); + const label = name && /^[A-Za-z_$][\w$]*$/.test(name) ? `${name}: ` : ''; + return label + type; + }); + return `[${items.join(', ')}]`; + } + if (schema.items) return `${registry.nameOf(schema.items, `${context}Item`)}[]`; + return 'unknown[]'; + } + + function registerArguments(schema, context) { + if (Array.isArray(schema?.anyOf)) { + for (const variant of schema.anyOf) registerArguments(variant, context); + } else if (Array.isArray(schema?.prefixItems)) { + for (const [index, item] of schema.prefixItems.entries()) { + registry.registerRoot(item, `${context}Argument${index + 1}`); + } + } else if (schema?.items) { + registry.registerRoot(schema.items, `${context}Item`); + } + } + + const operations = []; + const addresses = new Set(); + for (const [id, operation] of Object.entries(document.operations)) { + if (!['send', 'receive'].includes(operation.action)) throw new Error(`${id}: invalid action`); + const channel = pointer(document, operation.channel?.$ref); + if (channel.address !== null && (typeof channel.address !== 'string' || !channel.address.startsWith('/'))) { + throw new Error(`${id}: invalid namespace`); + } + if (channel.address === null) addresses.add('/'); + else addresses.add(channel.address); + if (!Array.isArray(operation.messages) || operation.messages.length !== 1) { + throw new Error(`${id}: expected exactly one event message`); + } + const message = pointer(document, operation.messages[0].$ref); + const event = message?.[EXTENSION]?.event; + if (typeof event !== 'string' || !event) throw new Error(`${id}: missing event name`); + operations.push({ id, operation, channel, message, event }); + } + operations.sort((a, b) => a.id.localeCompare(b.id)); + + const namespaces = new Map([...addresses].sort().map((address) => [address, { + ClientToServerEvents: new Map(), ServerToClientEvents: new Map(), + }])); + const unscoped = { ClientToServerEvents: new Map(), ServerToClientEvents: new Map() }; + for (const { id, operation, channel, message, event } of operations) { + registerArguments(message.payload, `${safeName(channel.address || 'All')}${safeName(event)}Payload`); + if (operation.reply) { + registerArguments(pointer(document, operation.reply.messages[0].$ref).payload, + `${safeName(channel.address || 'All')}${safeName(event)}Ack`); + } + } + const reservedNames = new Set([...addresses].map((address) => address === '/' ? 'Root' : safeName(address))); + reservedNames.add('Unscoped'); + const uniqueModels = registry.resolve(reservedNames); + for (const { id, operation, channel, message, event } of operations) { + const context = `${safeName(channel.address || 'All')}${safeName(event)}Payload`; + const metadata = message[EXTENSION]?.arguments; + if (metadata !== undefined && (!Array.isArray(metadata) || metadata.some((item) => typeof item?.name !== 'string'))) { + throw new Error(`${id}: invalid argument names`); + } + const payload = argumentsType(message.payload, context, metadata); + let ack; + if (operation.reply) { + if (operation.reply[EXTENSION]?.ack !== true || operation.reply.messages?.length !== 1) { + throw new Error(`${id}: reply is not a Socket.IO ACK`); + } + ack = argumentsType(pointer(document, operation.reply.messages[0].$ref).payload, + `${safeName(channel.address || 'All')}${safeName(event)}Ack`); + } else if (operation[EXTENSION]?.ack !== 'unspecified') { + throw new Error(`${id}: missing ACK declaration`); + } + const signature = ack === undefined + ? `(...args: [...${payload}, ack?: (...args: unknown[]) => void]) => void` + : `(...args: [...${payload}, ack?: (...args: ${ack}) => void]) => void`; + const side = (role === 'server') === (operation.action === 'receive') + ? 'ClientToServerEvents' : 'ServerToClientEvents'; + const except = channel[EXTENSION]?.namespace?.except || []; + const targets = channel.address === null + ? [...addresses].filter((address) => !except.includes(address)) + : [channel.address]; + if (channel.address === null) { + if (channel[EXTENSION]?.namespace?.scope !== 'all' || !Array.isArray(except)) { + throw new Error(`${id}: unscoped event lacks fallback semantics`); + } + unscoped[side].set(event, signature); + } + for (const address of targets) { + const events = namespaces.get(address)[side]; + if (events.has(event) && events.get(event) !== signature) { + throw new Error(`${id}: conflicting ${event} contract in ${address}`); + } + events.set(event, signature); + } + } + + const output = [ + '// Generated from a Pydantic-SocketIO AsyncAPI contract. Do not edit.', + '// Client: Socket.', + '// Server: Server for /; Namespace<...> for other paths.', + ]; + const declarations = await registry.declarations(uniqueModels); + if (declarations) output.push(declarations); + const used = new Set(); + function render(name, sides, path) { + output.push(`export namespace ${name} {`); + if (path !== undefined) output.push(` export const path = ${JSON.stringify(path)};`); + for (const side of ['ClientToServerEvents', 'ServerToClientEvents']) { + output.push(` export interface ${side} {`); + for (const [event, signature] of sides[side]) { + output.push(` ${JSON.stringify(event)}: ${signature};`); + } + output.push(' }'); + } + output.push('}'); + } + for (const [address, sides] of namespaces) render(namespaceName(address, used), sides, address); + if (unscoped.ClientToServerEvents.size || unscoped.ServerToClientEvents.size) { + render(namespaceName('Unscoped', used), unscoped); + } + return output.join('\n') + '\n'; +} + +module.exports = { generate }; diff --git a/packages/codegen/test/e2e.cjs b/packages/codegen/test/e2e.cjs new file mode 100644 index 0000000..1f7459f --- /dev/null +++ b/packages/codegen/test/e2e.cjs @@ -0,0 +1,110 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const packageRoot = path.resolve(__dirname, '..'); +const repository = path.resolve(packageRoot, '..', '..'); +const generated = path.join(__dirname, 'generated'); +const cli = path.join(packageRoot, 'bin', 'generate.cjs'); +const tsc = path.join(packageRoot, 'node_modules', '.bin', 'tsc'); + +function run(command, args, cwd = packageRoot) { + const result = spawnSync(command, args, { cwd, encoding: 'utf8' }); + if (result.error) throw result.error; + return result; +} + +function success(command, args, cwd) { + const result = run(command, args, cwd); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result; +} + +fs.mkdirSync(generated, { recursive: true }); +success('uv', ['run', '--locked', '--no-sync', 'python', + 'packages/codegen/test/export_contract.py'], repository); +for (const role of ['server', 'client']) { + success(process.execPath, [cli, path.join(generated, `${role}.json`), + '-o', path.join(generated, `${role}.ts`)]); + const first = fs.readFileSync(path.join(generated, `${role}.ts`), 'utf8'); + success(process.execPath, [cli, path.join(generated, `${role}.json`), + '-o', path.join(generated, `${role}.ts`)]); + assert.equal(fs.readFileSync(path.join(generated, `${role}.ts`), 'utf8'), first); + for (const model of ['Request', 'Nested', 'Answer']) { + assert.match(first, new RegExp(`export interface ${model} \\{`)); + assert.equal((first.match(new RegExp(`export interface ${model} \\{`, 'g')) || []).length, 1); + } + assert.doesNotMatch(first, /Model\d+|Value\d+|PydanticSocketIOModels/); + if (role === 'server') { + assert.match(first, /export interface Notice \{/); + assert.match(first, /first: Request, second: number/); + assert.match(first, /payload: Request/); + assert.match(first, /export interface ChatNumericStatus \{\s+value: number;/); + assert.match(first, /export interface ChatTextStatus \{\s+value: string;/); + assert.match(first, /export interface ChatNumericContainerItem \{\s+value: number;/); + assert.match(first, /export interface ChatTextContainerItem \{\s+value: string;/); + } +} +success(tsc, ['-p', path.join(packageRoot, 'tsconfig.json')]); + +const negative = path.join(__dirname, 'typecheck-negative.ts'); +for (const [file, expected] of [['typecheck-ts-client.ts', 7], ['typecheck-ts-server.ts', 5]]) { + const original = fs.readFileSync(path.join(__dirname, file), 'utf8'); + try { + fs.writeFileSync(negative, original.replaceAll('@ts-expect-error', 'negative assertion')); + const result = run(tsc, ['--strict', '--noEmit', '--skipLibCheck', + '--target', 'ES2020', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', negative]); + assert.notEqual(result.status, 0, `Removing negative assertions from ${file} should fail`); + assert.equal((result.stdout.match(/error TS/g) || []).length, expected, result.stdout); + } finally { + fs.rmSync(negative, { force: true }); + } +} + +const invalid = path.join(generated, 'invalid.json'); +const invalidOutput = path.join(generated, 'invalid.ts'); +const document = JSON.parse(fs.readFileSync(path.join(generated, 'server.json'), 'utf8')); +document['x-pydantic-socketio'].formatVersion = 2; +fs.writeFileSync(invalid, JSON.stringify(document)); +fs.rmSync(invalidOutput, { force: true }); +const rejected = run(process.execPath, [cli, invalid, '-o', invalidOutput]); +assert.notEqual(rejected.status, 0); +assert.match(rejected.stderr, /formatVersion 1/); +assert.equal(fs.existsSync(invalidOutput), false); + +// Documents emitted before argument names were added remain valid version 1 inputs. +const legacy = JSON.parse(fs.readFileSync(path.join(generated, 'server.json'), 'utf8')); +for (const message of Object.values(legacy.components.messages)) { + if (message["x-pydantic-socketio"]) delete message["x-pydantic-socketio"].arguments; +} +const legacyInput = path.join(generated, 'legacy.json'); +const legacyOutput = path.join(generated, 'legacy.ts'); +fs.writeFileSync(legacyInput, JSON.stringify(legacy)); +success(process.execPath, [cli, legacyInput, '-o', legacyOutput]); +assert.match(fs.readFileSync(legacyOutput, 'utf8'), /"many": \(\.\.\.args: \[\.\.\.\[Request, number\]/); + +const reordered = JSON.parse(fs.readFileSync(path.join(generated, 'server.json'), 'utf8')); +reordered.operations = Object.fromEntries(Object.entries(reordered.operations).reverse()); +reordered.components.schemas = Object.fromEntries(Object.entries(reordered.components.schemas).reverse()); +const reorderedInput = path.join(generated, 'reordered.json'); +const reorderedOutput = path.join(generated, 'reordered.ts'); +fs.writeFileSync(reorderedInput, JSON.stringify(reordered)); +success(process.execPath, [cli, reorderedInput, '-o', reorderedOutput]); +assert.equal(fs.readFileSync(reorderedOutput, 'utf8'), fs.readFileSync(path.join(generated, 'server.ts'), 'utf8')); + +const scopedOnly = JSON.parse(fs.readFileSync(path.join(generated, 'client.json'), 'utf8')); +scopedOnly.operations = Object.fromEntries(Object.entries(scopedOnly.operations).filter(([, operation]) => { + const channelName = operation.channel.$ref.split('/').at(-1); + return scopedOnly.channels[channelName].address === '/chat'; +})); +const scopedInput = path.join(generated, 'scoped-only.json'); +const scopedOutput = path.join(generated, 'scoped-only.ts'); +fs.writeFileSync(scopedInput, JSON.stringify(scopedOnly)); +success(process.execPath, [cli, scopedInput, '-o', scopedOutput]); +const scopedSource = fs.readFileSync(scopedOutput, 'utf8'); +assert.match(scopedSource, /export namespace Chat \{/); +assert.doesNotMatch(scopedSource, /export namespace Root \{/); + +console.log('Python → AsyncAPI JSON → CLI → Socket.IO TypeScript: passed'); +console.log('Negative compile assertions: 12 verified errors; invalid contract rejected'); diff --git a/packages/codegen/test/export_contract.py b/packages/codegen/test/export_contract.py new file mode 100644 index 0000000..9ac8bbf --- /dev/null +++ b/packages/codegen/test/export_contract.py @@ -0,0 +1,82 @@ +"""Produce real AsyncAPI inputs for the TypeScript consumer test.""" + +import json +from pathlib import Path +from typing import Optional, Tuple, Union + +from pydantic import BaseModel, create_model + +import pydantic_socketio + + +class Nested(BaseModel): + count: int + + +class Request(BaseModel): + nested: Nested + label: Optional[str] = None + + +class Answer(BaseModel): + ok: bool + result: Union[int, str] + + +class Notice(BaseModel): + message: str + + +server = pydantic_socketio.Server(async_mode="threading") + + +@server.on("ping") +def ping(sid: str) -> None: + return None + + +@server.on("shared", namespace="/chat") +def shared(sid: str, payload: Request) -> Answer: + return Answer(ok=True, result=payload.nested.count) + + +@server.on("many", namespace="/chat") +def many(sid: str, first: Request, second: int) -> Tuple[int, str]: + return second, first.label or "" + + +server.register_emit( + "shared", Tuple[int, str], namespace="/chat", ack_type=Tuple[int, str] +) +server.register_emit("tick", int, namespace="/", ack_type=None) +server.register_emit("fallback", Request) +server.register_emit("fallback", Answer, namespace="/chat") +server.register_emit("notice", Notice, namespace="/chat") + +# Same display name, different structures: the generated names must remain distinct. +numeric_status = create_model("Status", __module__="numeric_status", value=(int, ...)) +text_status = create_model("Status", __module__="text_status", value=(str, ...)) +server.register_emit("numeric_status", numeric_status, namespace="/chat") +server.register_emit("text_status", text_status, namespace="/chat") +numeric_item = create_model("Item", __module__="numeric_item", value=(int, ...)) +text_item = create_model("Item", __module__="text_item", value=(str, ...)) +numeric_container = create_model("Container", item=(numeric_item, ...)) +text_container = create_model("Container", item=(text_item, ...)) +server.register_emit("numeric_container", numeric_container, namespace="/chat") +server.register_emit("text_container", text_container, namespace="/chat") + +client = pydantic_socketio.Client() + + +@client.on("client_event", namespace="/chat") +def client_event(payload: Request) -> Answer: + return Answer(ok=True, result=payload.nested.count) + + +client.register_emit("client_send", Request, namespace="/", ack_type=Answer) + +destination = Path(__file__).parent / "generated" +destination.mkdir(exist_ok=True) +for name, sio in (("server", server), ("client", client)): + document = pydantic_socketio.asyncapi_schema(sio, title="Type test", version="1") + (destination / f"{name}.json").write_text(json.dumps(document, indent=2)) diff --git a/packages/codegen/test/live.cjs b/packages/codegen/test/live.cjs new file mode 100644 index 0000000..3241ea2 --- /dev/null +++ b/packages/codegen/test/live.cjs @@ -0,0 +1,93 @@ +const assert = require('node:assert/strict'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); +const http = require('node:http'); +const { Server } = require('socket.io'); +const { io } = require('socket.io-client'); + +const repository = path.resolve(__dirname, '..', '..', '..'); +const python = path.join(repository, '.venv', 'bin', 'python'); + +async function port() { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const value = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return value; +} + +function pythonProcess(mode, number) { + const child = spawn(python, [path.join(__dirname, 'live_python.py'), mode, String(number)], { + cwd: repository, stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let errors = ''; + child.stdout.on('data', (chunk) => { output += chunk; }); + child.stderr.on('data', (chunk) => { errors += chunk; }); + return { child, output: () => output, errors: () => errors }; +} + +async function pythonServerToTsClient() { + const number = await port(); + const process = pythonProcess('server', number); + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Python server startup timed out: ' + process.errors())), 10000); + process.child.stdout.on('data', (chunk) => { + if (chunk.toString().includes('READY')) { clearTimeout(timer); resolve(); } + }); + process.child.on('exit', (code) => { clearTimeout(timer); reject(new Error(`Python server exited ${code}: ${process.errors()}`)); }); + }); + const socket = io(`http://127.0.0.1:${number}/chat`, { transports: ['polling'], reconnection: false }); + try { + const notice = new Promise((resolve) => socket.once('notice', resolve)); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }); + const ack = await socket.timeout(5000).emitWithAck('ask', { value: 7 }); + assert.deepEqual(ack, { doubled: 14 }); + assert.deepEqual(await notice, { doubled: 14 }); + } finally { socket.disconnect(); } + } finally { + process.child.kill(); + } +} + +async function tsServerToPythonClient() { + const number = await port(); + const httpServer = http.createServer(); + const server = new Server(httpServer); + const peerAck = new Promise((resolve, reject) => { + server.of('/chat').on('connection', (socket) => { + socket.on('ask', (request, ack) => { + assert.deepEqual(request, { value: 7 }); + ack({ doubled: 14 }); + socket.timeout(5000).emit('notice', { value: 3 }, (error, answer) => { + if (error) reject(error); + else { + socket.emit('confirmed'); + resolve(answer); + } + }); + }); + }); + }); + await new Promise((resolve) => httpServer.listen(number, '127.0.0.1', resolve)); + try { + const process = pythonProcess('client', number); + const exit = await new Promise((resolve) => process.child.on('exit', resolve)); + assert.equal(exit, 0, process.errors() + process.output()); + assert.match(process.output(), /PYTHON_CLIENT_OK/); + assert.deepEqual(await peerAck, { doubled: 6 }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +const timer = setTimeout(() => { console.error('Live Socket.IO check timed out'); process.exit(1); }, 30000); +(async () => { await pythonServerToTsClient(); await tsServerToPythonClient(); })() + .then(() => console.log('Python server ↔ TypeScript client; TypeScript server ↔ Python client: passed')) + .catch((error) => { console.error(error); process.exitCode = 1; }) + .finally(() => clearTimeout(timer)); diff --git a/packages/codegen/test/live_python.py b/packages/codegen/test/live_python.py new file mode 100644 index 0000000..7d6b0cb --- /dev/null +++ b/packages/codegen/test/live_python.py @@ -0,0 +1,82 @@ +"""Real Socket.IO counterpart for the codegen integration check.""" + +import sys +from threading import Event +from socketserver import ThreadingMixIn +from wsgiref.simple_server import WSGIServer, make_server + +from pydantic import BaseModel +import socketio + +import pydantic_socketio + + +class Request(BaseModel): + value: int + + +class Answer(BaseModel): + doubled: int + + +class ThreadedWSGIServer(ThreadingMixIn, WSGIServer): + daemon_threads = True + + +def serve(port: int) -> None: + server = pydantic_socketio.Server(async_mode="threading", async_handlers=False) + server.register_emit("notice", Answer, namespace="/chat") + + @server.on("ask", namespace="/chat") + def ask(sid: str, request: Request) -> Answer: + server.emit( + "notice", Answer(doubled=request.value * 2), to=sid, namespace="/chat" + ) + return Answer(doubled=request.value * 2) + + app = socketio.WSGIApp(server) + with make_server("127.0.0.1", port, app, server_class=ThreadedWSGIServer) as httpd: + print("READY", flush=True) + httpd.serve_forever() + + +def connect(port: int) -> None: + client = pydantic_socketio.Client() + observed = [] + notice_received = Event() + confirmed = Event() + + @client.on("notice", namespace="/chat") + def notice(request: Request) -> Answer: + observed.append(request.value) + notice_received.set() + return Answer(doubled=request.value * 2) + + client.register_emit("ask", Request, namespace="/chat", ack_type=Answer) + + @client.on("confirmed", namespace="/chat") + def confirmed_handler() -> None: + confirmed.set() + + client.connect( + f"http://127.0.0.1:{port}", namespaces=["/chat"], transports=["polling"] + ) + response = client.call( + "ask", Request(value=7), namespace="/chat", response_model=Answer + ) + assert response.doubled == 14, response + assert notice_received.wait(5), "notice was not received" + assert observed == [3], observed + assert confirmed.wait(5), "server did not confirm the notice ACK" + client.disconnect() + print("PYTHON_CLIENT_OK", flush=True) + + +if __name__ == "__main__": + port = int(sys.argv[2]) + if sys.argv[1] == "server": + serve(port) + elif sys.argv[1] == "client": + connect(port) + else: + raise ValueError(sys.argv[1]) diff --git a/packages/codegen/test/typecheck-ts-client.ts b/packages/codegen/test/typecheck-ts-client.ts new file mode 100644 index 0000000..13080c6 --- /dev/null +++ b/packages/codegen/test/typecheck-ts-client.ts @@ -0,0 +1,43 @@ +import { io, Socket } from 'socket.io-client'; +import { Root, Chat } from './generated/server'; + +const root: Socket = io(Root.path); +root.emit('ping'); +root.emit('ping', () => {}); +root.on('fallback', data => { const n: number = data.nested.count; void n; }); +root.on('tick', (data, ack) => { const n: number = data; void n; ack?.(); }); + +const chat: Socket = io(Chat.path); +chat.emit('shared', { nested: { count: 1 } }, answer => { + const value: number | string = answer.result; + void value; +}); +chat.emit('many', { nested: { count: 1 }, label: null }, 2, (n, s) => { + const number: number = n; + const text: string = s; + void number; void text; +}); +chat.on('shared', (n, s, ack) => { ack?.(n, s); }); +chat.on('fallback', data => { const ok: boolean = data.ok; void ok; }); + +async function request() { + const answer = await chat.emitWithAck('shared', { nested: { count: 1 } }); + const ok: boolean = answer.ok; + void ok; +} +void request; + +// @ts-expect-error incorrect event +chat.emit('typo', { nested: { count: 1 } }); +// @ts-expect-error incorrect nested payload +chat.emit('shared', { nested: { count: 'wrong' } }); +// @ts-expect-error missing second event argument +chat.emit('many', { nested: { count: 1 } }); +// @ts-expect-error incorrect ACK result use +chat.emit('shared', { nested: { count: 1 } }, answer => { const n: number = answer.ok; void n; }); +// @ts-expect-error incorrect ACK argument types +chat.on('shared', (n, s, ack) => { ack?.('wrong', 1); }); +// @ts-expect-error scoped override replaces the unscoped fallback +chat.on('fallback', data => { const n: number = data.nested.count; void n; }); +// @ts-expect-error an explicitly empty ACK takes no arguments +root.on('tick', (data, ack) => { ack?.('wrong'); }); diff --git a/packages/codegen/test/typecheck-ts-server.ts b/packages/codegen/test/typecheck-ts-server.ts new file mode 100644 index 0000000..1aafb0e --- /dev/null +++ b/packages/codegen/test/typecheck-ts-server.ts @@ -0,0 +1,31 @@ +import { Server, type Namespace } from 'socket.io'; +import { Root, Chat, type Answer, type Request } from './generated/client'; + +const server = new Server(); +server.on('connection', socket => { + socket.on('client_send', (request, ack) => { + const count: number = request.nested.count; + const answer: Answer = { ok: true, result: count }; + ack?.(answer); + }); +}); + +const chat: Namespace = server.of(Chat.path); +chat.on('connection', socket => { + const notice: Request = { nested: { count: 2 } }; + socket.emit('client_event', notice, answer => { + const ok: boolean = answer.ok; + void ok; + }); + // @ts-expect-error client_event is sent by the server, not received by it + socket.on('client_event', () => {}); +}); + +// @ts-expect-error unknown event on TypeScript server +server.emit('missing', { nested: { count: 1 } }); +// @ts-expect-error incorrect payload on TypeScript server +chat.emit('client_event', { nested: { count: 'wrong' } }); +// @ts-expect-error wrong direction: client_send belongs to client-to-server +server.emit('client_send', { nested: { count: 1 } }); +// @ts-expect-error client_send is on the root namespace, not /chat +chat.emit('client_send', { nested: { count: 1 } }); diff --git a/packages/codegen/tsconfig.json b/packages/codegen/tsconfig.json new file mode 100644 index 0000000..b15b169 --- /dev/null +++ b/packages/codegen/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["test/typecheck-ts-client.ts", "test/typecheck-ts-server.ts", "test/generated/*.ts"] +} diff --git a/pyproject.toml b/pyproject.toml index 2bdcb94..416edd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,12 @@ docs = [ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.sdist] +exclude = ["/packages"] + +[tool.hatch.build.targets.wheel] +packages = ["src/pydantic_socketio"] + [dependency-groups] dev = [ "fastapi[standard]>=0.115.11", diff --git a/src/pydantic_socketio/_asyncapi.py b/src/pydantic_socketio/_asyncapi.py index 042a2f0..199937d 100644 --- a/src/pydantic_socketio/_asyncapi.py +++ b/src/pydantic_socketio/_asyncapi.py @@ -161,9 +161,14 @@ def generate_asyncapi( channel_name = name + "_channel" message_name = name + "_message" context = key.direction + " " + repr(key.event) + " in " + repr(key.namespace) + message_extension: Dict[str, Any] = {"event": key.event} + if key.direction == "receive" and contract.payload_names: + message_extension["arguments"] = [ + {"name": argument} for argument in contract.payload_names + ] document["components"]["messages"][message_name] = { "name": key.event, - _EXTENSION: {"event": key.event}, + _EXTENSION: message_extension, "payload": ( _wire_schema( contract.payload_types[0], diff --git a/src/pydantic_socketio/_operation_contract.py b/src/pydantic_socketio/_operation_contract.py index 78798e7..8f574f2 100644 --- a/src/pydantic_socketio/_operation_contract.py +++ b/src/pydantic_socketio/_operation_contract.py @@ -31,6 +31,7 @@ class OperationContract: # expands tuple values into multiple ACK arguments and None into zero. # UNSPECIFIED means no type was declared; it does not mean no ACK occurs. ack_type: Any = UNSPECIFIED + payload_names: Tuple[str, ...] = () def send_contract( @@ -77,4 +78,9 @@ def receive_contract( ack_type = UNSPECIFIED elif ack_type is not None: ack_type = hints.get("return", ack_type) - return OperationContract(key, payload_types, ack_type) + payload_names = ( + tuple(parameter.name for parameter in positional[prefix:]) + if event not in ("connect", "disconnect") + else () + ) + return OperationContract(key, payload_types, ack_type, payload_names) diff --git a/tests/test_asyncapi.py b/tests/test_asyncapi.py index d47c6ac..70837e7 100644 --- a/tests/test_asyncapi.py +++ b/tests/test_asyncapi.py @@ -127,6 +127,12 @@ def receive(sid: str, first: Request, second: int) -> Tuple[int, str]: assert {item["action"] for item in operations} == {"send", "receive"} incoming = next(item for item in operations if item["action"] == "receive") assert operation_message(document, incoming)["payload"]["maxItems"] == 2 + assert operation_message(document, incoming)["x-pydantic-socketio"][ + "arguments" + ] == [ + {"name": "first"}, + {"name": "second"}, + ] assert reply_message(document, incoming)["payload"]["maxItems"] == 2 assert channel(document, incoming)["address"] == "/chat" @@ -152,6 +158,9 @@ def receive(data: Request) -> Response: operation = next(iter(document["operations"].values())) assert operation["action"] == "receive" assert operation_message(document, operation)["payload"]["maxItems"] == 1 + assert operation_message(document, operation)["x-pydantic-socketio"][ + "arguments" + ] == [{"name": "data"}] assert reply_message(document, operation)["payload"]["maxItems"] == 1