From 435f5dcf00e1a9f788bc97f51a367c273eb8f564 Mon Sep 17 00:00:00 2001 From: Jorge Zapata Date: Sat, 14 Mar 2026 20:35:52 +0100 Subject: [PATCH] Improve the infra Add CI checks and linters, same for the pre-commit. Fix all current code --- .github/workflows/copilot-setup-steps.yml | 14 +- .github/workflows/format.yml | 17 + .github/workflows/test.yml | 43 + .pre-commit-config.yaml | 47 + .../examples/generic_constructor_example.py | 90 +- girest/girest-client-generator.py | 70 +- girest/girest-dump-schema.py | 42 +- girest/girest-frida.py | 48 +- girest/girest/app.py | 47 +- girest/girest/callbacks.py | 230 ++--- girest/girest/generator.py | 363 ++++--- girest/girest/main.py | 910 ++++++++---------- girest/girest/resolvers.py | 861 ++++++++--------- girest/girest/uri_parser.py | 69 +- girest/girest/utils.py | 28 +- girest/girest/validators.py | 26 +- girest/poetry.lock | 165 +++- girest/pyproject.toml | 12 +- girest/tests/conftest.py | 29 +- girest/tests/e2e/conftest.py | 344 +++---- girest/tests/e2e/test_e2e_basic.py | 188 ++-- girest/tests/e2e/test_e2e_callbacks_nonsse.py | 213 ++-- girest/tests/e2e/test_e2e_callbacks_sse.py | 70 +- girest/tests/e2e/test_e2e_thread_affinity.py | 334 +++---- girest/tests/test_generator.py | 478 ++++----- girest/tests/test_schema.py | 309 +++--- girest/tests/test_uri_parser.py | 177 ++-- girest/tests/test_url_objects.py | 154 ++- gstaudit-server/gstaudit_server/app.py | 114 +-- gstaudit-server/pyproject.toml | 16 +- gstaudit/app/logs/page.tsx | 13 +- gstaudit/components/LinkEdge.tsx | 4 +- gstaudit/components/PipelineGraph.tsx | 19 +- gstaudit/eslint.config.mjs | 9 +- gstaudit/hooks/useCallbackRegistry.ts | 24 +- gstaudit/lib/callbacks.ts | 35 +- gstaudit/lib/server/callback-manager.ts | 15 +- gstaudit/lib/server/connection-manager.ts | 8 +- gstaudit/lib/server/websocket-handler.ts | 10 +- 39 files changed, 2710 insertions(+), 2935 deletions(-) create mode 100644 .github/workflows/format.yml create mode 100644 .github/workflows/test.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index b727a92..340aecc 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -1,16 +1,13 @@ -name: "Copilot Setup Steps" - +--- +name: Copilot Setup Steps # Automatically run the setup steps when they are changed to allow for easy validation, and # allow manual testing through the repository's "Actions" tab on: workflow_dispatch: push: - paths: - - .github/workflows/copilot-setup-steps.yml + paths: [.github/workflows/copilot-setup-steps.yml] pull_request: - paths: - - .github/workflows/copilot-setup-steps.yml - + paths: [.github/workflows/copilot-setup-steps.yml] jobs: copilot-setup-steps: runs-on: ubuntu-latest @@ -23,6 +20,5 @@ jobs: sudo apt-get install -y \ libgirepository1.0-dev libgstreamer1.0-dev libcairo2-dev \ gstreamer1.0-tools python3-poetry - - name: Disable ptrace restrictions - run: "echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope" + run: echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..306ebca --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,17 @@ +--- +name: Format +on: + pull_request: + branches: ['**'] +jobs: + format: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Format check + uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..37908ad --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +--- +name: Tests +on: + pull_request: + branches: ['**'] + push: + branches: [main] +jobs: + test-girest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + python3-gi \ + gir1.2-gstreamer-1.0 \ + gir1.2-gst-plugins-base-1.0 \ + gstreamer1.0-tools \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + libgirepository1.0-dev \ + libcairo2-dev + - name: Allow ptrace for Frida + run: echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope + - name: Install Python dependencies + working-directory: ./girest + run: poetry install --no-interaction + - name: Run tests + working-directory: ./girest + run: poetry run pytest -v diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2b4b284 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +--- +repos: + # Python projects: girest and gstaudit-server + - repo: https://github.com/floatingpurr/sync_with_poetry + rev: 1.0.0 + hooks: + - id: sync_with_poetry + + # TOML formatting and linting + - repo: https://github.com/ComPWA/taplo-pre-commit + rev: v0.9.3 + hooks: + - id: taplo-format + files: \.toml$ + - id: taplo-lint + files: \.toml$ + # Skip if offline (catalog fetch fails) + stages: [manual] + + # YAML linting + - repo: https://github.com/lyz-code/yamlfix + rev: 1.17.0 + hooks: + - id: yamlfix + files: \.ya?ml$ + + # ruff - lint and formatting for Python + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.3 + hooks: + - id: ruff + args: [--fix, --show-fixes] + files: ^(girest|gstaudit-server)/.*\.py$ + - id: ruff-format + files: ^(girest|gstaudit-server)/.*\.py$ + + # Node.js/TypeScript project: gstaudit + # Uses system hook to run npm-installed eslint + - repo: local + hooks: + - id: eslint + name: eslint (gstaudit) + entry: bash -c 'cd gstaudit && ([ -d node_modules ] || npm install) && npm + run lint -- --fix' + language: system + files: ^gstaudit/.*\.(ts|tsx|js|jsx)$ + pass_filenames: false diff --git a/girest/examples/generic_constructor_example.py b/girest/examples/generic_constructor_example.py index cfb647d..329ddfd 100644 --- a/girest/examples/generic_constructor_example.py +++ b/girest/examples/generic_constructor_example.py @@ -11,15 +11,13 @@ For this demo, we just show the generated schema and TypeScript. """ -import sys import os -import json +import sys # Add parent directory to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from girest.main import GIRest -from girest.generator import TypeScriptGenerator def show_gvalue_example(): @@ -28,52 +26,52 @@ def show_gvalue_example(): print("EXAMPLE: Using Generic Constructor for GObject.Value") print("=" * 80) print() - + # Generate schema - girest = GIRest('GObject', '2.0') + girest = GIRest("GObject", "2.0") spec = girest.generate() schema = spec.to_dict() - + # Show the endpoints print("1. Generic Constructor Endpoint") print("-" * 80) - new_path = '/GObject/Value/new' - operation = schema['paths'][new_path]['get'] - + new_path = "/GObject/Value/new" + operation = schema["paths"][new_path]["get"] + print(f"Endpoint: GET {new_path}") print(f"Operation ID: {operation['operationId']}") - print(f"Returns: pointer to allocated GValue") + print("Returns: pointer to allocated GValue") print() print("Example curl command:") print(f" curl http://localhost:9000{new_path}") print(' # Returns: {"return": "0x7f1234567890"}') print() - + print("2. Generic Destructor Endpoint") print("-" * 80) - free_path = '/GObject/Value/{self}/free' - operation = schema['paths'][free_path]['get'] - + free_path = "/GObject/Value/{self}/free" + operation = schema["paths"][free_path]["get"] + print(f"Endpoint: GET {free_path}") print(f"Operation ID: {operation['operationId']}") - print(f"Parameter: self (pointer to free)") - print(f"Returns: 204 No Content") + print("Parameter: self (pointer to free)") + print("Returns: 204 No Content") print() print("Example curl command:") - print(' curl http://localhost:9000/GObject/Value/0x7f1234567890/free') + print(" curl http://localhost:9000/GObject/Value/0x7f1234567890/free") print() - + print("3. Using GValue Methods") print("-" * 80) # Find some GValue methods - value_methods = [p for p in schema['paths'] if '/Value/' in p and p != new_path and p != free_path] + value_methods = [p for p in schema["paths"] if "/Value/" in p and p != new_path and p != free_path] print(f"GValue has {len(value_methods)} methods available") print("Examples:") for path in value_methods[:5]: - method_name = path.split('/')[-1] + method_name = path.split("/")[-1] print(f" - {method_name}") print() - + print("4. Complete Usage Flow") print("-" * 80) print(""" @@ -94,7 +92,7 @@ def show_gvalue_example(): # Step 5: Free the memory $ curl http://localhost:9000/GObject/Value/0x7f1234567890/free """) - + print("5. TypeScript Usage") print("-" * 80) print(""" @@ -127,31 +125,31 @@ def show_gst_meta_example(): print("EXAMPLE: Using Generic Constructor for Gst.Meta") print("=" * 80) print() - + # Generate schema - girest = GIRest('Gst', '1.0') + girest = GIRest("Gst", "1.0") spec = girest.generate() schema = spec.to_dict() - + # Show the endpoints - new_path = '/Gst/Meta/new' - operation = schema['paths'][new_path]['get'] - + new_path = "/Gst/Meta/new" + operation = schema["paths"][new_path]["get"] + print("1. GstMeta Allocation") print("-" * 80) print(f"Endpoint: GET {new_path}") print() - + # Find GstMeta methods - meta_methods = [p for p in schema['paths'] if '/Meta/' in p and '/new' not in p and '/free' not in p] + meta_methods = [p for p in schema["paths"] if "/Meta/" in p and "/new" not in p and "/free" not in p] print(f"2. Available GstMeta Methods ({len(meta_methods)} total)") print("-" * 80) for path in meta_methods: - method_name = path.split('/')[-1] - operation = list(schema['paths'][path].values())[0] + method_name = path.split("/")[-1] + operation = list(schema["paths"][path].values())[0] print(f" - {method_name:30} {operation.get('summary', '')}") print() - + print("3. TypeScript Example") print("-" * 80) print(""" @@ -186,41 +184,41 @@ def show_summary(): print("SUMMARY: All Structs with Generic Constructors") print("=" * 80) print() - + # Collect from multiple namespaces all_structs = [] - - for namespace, version in [('GObject', '2.0'), ('Gst', '1.0')]: + + for namespace, version in [("GObject", "2.0"), ("Gst", "1.0")]: girest = GIRest(namespace, version) spec = girest.generate() schema = spec.to_dict() - - for path, operations in schema['paths'].items(): + + for path, operations in schema["paths"].items(): for method, operation in operations.items(): - if operation.get('x-gi-generic') and operation.get('x-gi-constructor'): - op_id = operation['operationId'] - struct_name = op_id.replace(f'{namespace}-', '').replace('-new', '') + if operation.get("x-gi-generic") and operation.get("x-gi-constructor"): + op_id = operation["operationId"] + struct_name = op_id.replace(f"{namespace}-", "").replace("-new", "") all_structs.append((namespace, struct_name)) - + # Group by namespace by_namespace = {} for ns, name in all_structs: if ns not in by_namespace: by_namespace[ns] = [] by_namespace[ns].append(name) - + for ns, structs in sorted(by_namespace.items()): print(f"{ns} Namespace ({len(structs)} structs):") print("-" * 80) for name in sorted(structs): print(f" - {name}") print() - + total = sum(len(s) for s in by_namespace.values()) print(f"Total: {total} structs with generic constructors") -if __name__ == '__main__': +if __name__ == "__main__": show_gvalue_example() show_gst_meta_example() show_summary() diff --git a/girest/girest-client-generator.py b/girest/girest-client-generator.py index f8b8480..ff321f8 100755 --- a/girest/girest-client-generator.py +++ b/girest/girest-client-generator.py @@ -5,89 +5,63 @@ This tool generates TypeScript client bindings from GObject introspection data, creating proper class hierarchies with methods organized by their tags. """ + import argparse -import json -import sys import os +import sys # Add the girest module to the path current_dir = os.path.dirname(os.path.abspath(__file__)) -girest_dir = os.path.join(current_dir, 'girest') +girest_dir = os.path.join(current_dir, "girest") sys.path.insert(0, girest_dir) -import gi +import gi # noqa: E402 + gi.require_version("GIRepository", "2.0") -from gi.repository import GIRepository -from apispec import APISpec -# Import GIRest and TypeScriptGenerator -from main import GIRest -from generator import TypeScriptGenerator +from generator import TypeScriptGenerator # noqa: E402 +from main import GIRest # noqa: E402 def main(): """Main entry point for girest-ts tool.""" - parser = argparse.ArgumentParser( - description="Generate TypeScript bindings from GObject introspection data" - ) - parser.add_argument( - "namespace", - help="GObject namespace (e.g., 'Gst', 'GLib', 'Gtk')" - ) - parser.add_argument( - "version", - help="Namespace version (e.g., '1.0', '2.0')" - ) - parser.add_argument( - "-o", "--output", - help="Output file path (default: stdout)", - default=None - ) - parser.add_argument( - "--host", - default="localhost", - help="Host for REST API calls (default: localhost)" - ) - parser.add_argument( - "--port", - type=int, - default=9000, - help="Port for REST API calls (default: 9000)" - ) - parser.add_argument( - "--base-path", - default="", - help="Base path for REST API calls (default: '')" - ) + parser = argparse.ArgumentParser(description="Generate TypeScript bindings from GObject introspection data") + parser.add_argument("namespace", help="GObject namespace (e.g., 'Gst', 'GLib', 'Gtk')") + parser.add_argument("version", help="Namespace version (e.g., '1.0', '2.0')") + parser.add_argument("-o", "--output", help="Output file path (default: stdout)", default=None) + parser.add_argument("--host", default="localhost", help="Host for REST API calls (default: localhost)") + parser.add_argument("--port", type=int, default=9000, help="Port for REST API calls (default: 9000)") + parser.add_argument("--base-path", default="", help="Base path for REST API calls (default: '')") parser.add_argument( "--sse-only", action="store_true", - help="Use SSE-only mode: hide callback URLs, return int IDs, skip sync callbacks" + help="Use SSE-only mode: hide callback URLs, return int IDs, skip sync callbacks", ) - + args = parser.parse_args() - + try: # Generate the OpenAPI schema girest = GIRest(args.namespace, args.version, sse_only=args.sse_only) spec = girest.generate() openapi_schema = spec.to_dict() - + # Generate TypeScript bindings using Jinja2-based generator ts_gen = TypeScriptGenerator(openapi_schema, host=args.host, port=args.port, base_path=args.base_path) output = ts_gen.generate() - + # Write to file or stdout if args.output: - with open(args.output, 'w') as f: + with open(args.output, "w") as f: f.write(output) print(f"Successfully generated bindings to {args.output}", file=sys.stderr) else: print(output) - + except Exception as e: print(f"Error: {e}", file=sys.stderr) import traceback + traceback.print_exc(file=sys.stderr) sys.exit(1) diff --git a/girest/girest-dump-schema.py b/girest/girest-dump-schema.py index 8f21122..9092ed7 100755 --- a/girest/girest-dump-schema.py +++ b/girest/girest-dump-schema.py @@ -4,65 +4,55 @@ This tool generates OpenAPI schema in JSON format from GObject introspection data. """ + import argparse import json -import sys import os +import sys # Add the girest module to the path current_dir = os.path.dirname(os.path.abspath(__file__)) -girest_dir = os.path.join(current_dir, 'girest') +girest_dir = os.path.join(current_dir, "girest") sys.path.insert(0, girest_dir) -from main import GIRest +from main import GIRest # noqa: E402 def main(): """Main entry point for girest-dump-schema tool.""" - parser = argparse.ArgumentParser( - description="Generate OpenAPI schema from GObject introspection data" - ) - parser.add_argument( - "namespace", - help="GObject namespace (e.g., 'Gst', 'GLib', 'Gtk')" - ) - parser.add_argument( - "version", - help="Namespace version (e.g., '1.0', '2.0')" - ) - parser.add_argument( - "-o", "--output", - help="Output file path (default: stdout)", - default=None - ) + parser = argparse.ArgumentParser(description="Generate OpenAPI schema from GObject introspection data") + parser.add_argument("namespace", help="GObject namespace (e.g., 'Gst', 'GLib', 'Gtk')") + parser.add_argument("version", help="Namespace version (e.g., '1.0', '2.0')") + parser.add_argument("-o", "--output", help="Output file path (default: stdout)", default=None) parser.add_argument( "--sse-only", action="store_true", - help="Use SSE-only mode: hide callback URLs, return int IDs, skip sync callbacks" + help="Use SSE-only mode: hide callback URLs, return int IDs, skip sync callbacks", ) - + args = parser.parse_args() - + try: # Generate the OpenAPI schema girest = GIRest(args.namespace, args.version, sse_only=args.sse_only) spec = girest.generate() openapi_schema = spec.to_dict() - + # Output the schema as JSON output = json.dumps(openapi_schema, indent=2) - + # Write to file or stdout if args.output: - with open(args.output, 'w') as f: + with open(args.output, "w") as f: f.write(output) print(f"Successfully generated schema to {args.output}", file=sys.stderr) else: print(output) - + except Exception as e: print(f"Error: {e}", file=sys.stderr) import traceback + traceback.print_exc(file=sys.stderr) sys.exit(1) diff --git a/girest/girest-frida.py b/girest/girest-frida.py index cc61e10..ef34d8b 100755 --- a/girest/girest-frida.py +++ b/girest/girest-frida.py @@ -5,64 +5,46 @@ This tool creates a development server that uses Frida to instrument a running process and expose its GObject introspection API as a REST API. """ + import argparse import sys -import os from girest.app import GIApp from girest.resolvers import FridaResolver + def main(): """Main entry point for girest-frida tool.""" - parser = argparse.ArgumentParser( - description="GIRest development server with Frida integration" - ) - parser.add_argument( - "namespace", - help="GObject namespace (e.g., 'Gst', 'GLib', 'Gtk')" - ) - parser.add_argument( - "version", - help="Namespace version (e.g., '1.0', '2.0')" - ) + parser = argparse.ArgumentParser(description="GIRest development server with Frida integration") + parser.add_argument("namespace", help="GObject namespace (e.g., 'Gst', 'GLib', 'Gtk')") + parser.add_argument("version", help="Namespace version (e.g., '1.0', '2.0')") + parser.add_argument("--pid", type=int, required=True, help="Process ID to instrument") + parser.add_argument("--port", type=int, default=9000, help="Port to run the server on (default: 9000)") parser.add_argument( - "--pid", - type=int, - required=True, - help="Process ID to instrument" - ) - parser.add_argument( - "--port", - type=int, - default=9000, - help="Port to run the server on (default: 9000)" - ) - parser.add_argument( - "--sse-buffer-size", - type=int, - default=100, - help="Size of the SSE event ring buffer (default: 100)" + "--sse-buffer-size", type=int, default=100, help="Size of the SSE event ring buffer (default: 100)" ) parser.add_argument( "--sse-only", action="store_true", - help="Use SSE-only mode: hide callback URLs, return int IDs, skip sync callbacks" + help="Use SSE-only mode: hide callback URLs, return int IDs, skip sync callbacks", ) - + args = parser.parse_args() try: - # Create the resolver with Frida - resolver = FridaResolver(args.namespace, args.version, args.pid, sse_buffer_size=args.sse_buffer_size, sse_only=args.sse_only) + resolver = FridaResolver( + args.namespace, args.version, args.pid, sse_buffer_size=args.sse_buffer_size, sse_only=args.sse_only + ) # Create the connexion AsyncApp # the actual defition by calling, for example, operation.parameters app = GIApp(__name__, args.namespace, args.version, resolver, sse_only=args.sse_only) app.run(port=args.port) - + except Exception as e: print(f"Error: {e}", file=sys.stderr) import traceback + traceback.print_exc(file=sys.stderr) sys.exit(1) diff --git a/girest/girest/app.py b/girest/girest/app.py index 98d9980..caaaa7e 100644 --- a/girest/girest/app.py +++ b/girest/girest/app.py @@ -1,46 +1,45 @@ -import logging -import sys import os +import sys # Add the girest module to the path current_dir = os.path.dirname(os.path.abspath(__file__)) -girest_dir = os.path.join(current_dir, 'girest') +girest_dir = os.path.join(current_dir, "girest") sys.path.insert(0, girest_dir) -from connexion import AsyncApp -from connexion.resolver import Resolver -from connexion.datastructures import MediaTypeDict -from connexion.validators import ( - JSONRequestBodyValidator, +from connexion import AsyncApp # noqa: E402 +from connexion.datastructures import MediaTypeDict # noqa: E402 +from connexion.validators import ( # noqa: E402 FormDataValidator, - MultiPartFormDataValidator, + JSONRequestBodyValidator, JSONResponseBodyValidator, + MultiPartFormDataValidator, TextResponseBodyValidator, ) -from .main import GIRest -from .resolvers import GIResolver -from .uri_parser import URITemplateParser -from .validators import GIRestParameterValidator +from .main import GIRest # noqa: E402 +from .resolvers import GIResolver # noqa: E402 +from .uri_parser import URITemplateParser # noqa: E402 +from .validators import GIRestParameterValidator # noqa: E402 + def patch_connexion_parameter_decorator(): """ Patch Connexion's _get_val_from_param to handle complex schemas. - + This patches the parameter decorator to handle schemas with allOf, anyOf, oneOf that don't have a direct "type" field. The patched function returns the validated parameter directly since our URI parser has already deserialized it. """ from connexion.decorators import parameter - from connexion.utils import is_null, is_nullable, make_type - + from connexion.utils import is_null, is_nullable + # Store the original function original_get_val_from_param = parameter._get_val_from_param - + def _get_val_from_param_girest(value, param_definitions): """ Cast a value according to its definition, handling complex schemas. - + For schemas with allOf/anyOf/oneOf (no direct "type" field), return the value as-is since it's already been parsed by our URI parser. """ @@ -53,12 +52,12 @@ def _get_val_from_param_girest(value, param_definitions): if "type" in param_schema: # Use original logic for schemas with type return original_get_val_from_param(value, param_definitions) - + # For complex schemas (allOf, anyOf, oneOf) or schemas with $ref, # the value has already been parsed by our custom URI parser # Return it as-is return value - + # Replace the function parameter._get_val_from_param = _get_val_from_param_girest @@ -72,7 +71,7 @@ def __init__( resolver: GIResolver, *, sse_only: bool = False, - default_base_path = None + default_base_path=None, ): # Generate the OpenAPI schema with specified buffer size girest = GIRest(namespace, version, sse_only=sse_only) @@ -80,7 +79,7 @@ def __init__( specd = spec.to_dict() super().__init__(import_name, resolver=resolver) - + # Create custom validator map with our enhanced parameter validator custom_validator_map = { "parameter": GIRestParameterValidator, @@ -98,14 +97,14 @@ def __init__( } ), } - + # Add the API with custom URI parser and validator self.add_api( specd, resolver=resolver, uri_parser_class=URITemplateParser, validator_map=custom_validator_map, - base_path=default_base_path + base_path=default_base_path, ) diff --git a/girest/girest/callbacks.py b/girest/girest/callbacks.py index 4fc5aaa..c3dca95 100644 --- a/girest/girest/callbacks.py +++ b/girest/girest/callbacks.py @@ -6,45 +6,44 @@ (fire-and-forget), with HMAC signature authentication. """ -import hmac import hashlib +import hmac import json import logging -import requests -import asyncio import time -from typing import Any, Optional, Dict, List from datetime import datetime, timezone -from collections import deque +from typing import Any, Dict, Optional + +import requests logger = logging.getLogger(__name__) class CallbackSecurity: """Handles HMAC signature generation for callback requests.""" - + def __init__(self, secret_key: str): """ Initialize with a shared secret key. - + Args: secret_key: Shared secret between girest and the callback receiver """ self.secret_key = secret_key.encode() if isinstance(secret_key, str) else secret_key - + def create_headers(self, payload: Dict[str, Any]) -> Dict[str, str]: """ Create signed headers for a callback HTTP request. - + The signature is generated using HMAC-SHA256 over a message consisting of the timestamp and the canonical JSON representation of the payload. - + Args: payload: Dictionary containing the callback data - + Returns: Dictionary of HTTP headers including signature and timestamp - + Example: >>> security = CallbackSecurity('my-secret-key') >>> headers = security.create_headers({'eventId': 'evt-001', 'data': 'test'}) @@ -52,62 +51,53 @@ def create_headers(self, payload: Dict[str, Any]) -> Dict[str, str]: """ timestamp = datetime.now(timezone.utc).isoformat() signature = self.sign_payload(payload, timestamp) - + return { - 'X-Callback-Signature': signature, - 'X-Callback-Timestamp': timestamp, - 'X-Event-Id': str(payload.get('eventId', '')), - 'Content-Type': 'application/json' + "X-Callback-Signature": signature, + "X-Callback-Timestamp": timestamp, + "X-Event-Id": str(payload.get("eventId", "")), + "Content-Type": "application/json", } - + def sign_payload(self, payload: Dict[str, Any], timestamp: str) -> str: """ Generate HMAC signature for a callback payload. - + The message to sign is: "{timestamp}.{canonical_json}" where canonical_json uses sorted keys and no whitespace. - + Args: payload: Dictionary to sign timestamp: ISO format timestamp string - + Returns: Hexadecimal signature string """ # Create canonical JSON representation (sorted keys, no whitespace) - canonical_json = json.dumps(payload, sort_keys=True, separators=(',', ':')) - + canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":")) + # Create message: timestamp.payload message = f"{timestamp}.{canonical_json}" - + # Generate HMAC-SHA256 signature - signature = hmac.new( - self.secret_key, - message.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - + signature = hmac.new(self.secret_key, message.encode("utf-8"), hashlib.sha256).hexdigest() + return signature - - def verify_signature( - self, - payload: Dict[str, Any], - timestamp: str, - provided_signature: str - ) -> bool: + + def verify_signature(self, payload: Dict[str, Any], timestamp: str, provided_signature: str) -> bool: """ Verify that a signature is valid for the given payload and timestamp. - + Args: payload: Dictionary to verify timestamp: ISO format timestamp string provided_signature: Signature to verify - + Returns: True if signature is valid, False otherwise """ expected_signature = self.sign_payload(payload, timestamp) - + # Use constant-time comparison to prevent timing attacks return hmac.compare_digest(provided_signature, expected_signature) @@ -115,24 +105,18 @@ def verify_signature( class CallbackHandler: """ Handles HTTP callback invocations for GObject callbacks. - + This class is responsible for: - Serializing callback arguments - Signing requests with HMAC - Making HTTP POST requests to callback URLs - Handling timeouts and errors gracefully """ - - def __init__( - self, - callback_url: Optional[str], - session_id: str, - secret: str, - timeout: int = 10 - ): + + def __init__(self, callback_url: Optional[str], session_id: str, secret: str, timeout: int = 10): """ Initialize the callback handler. - + Args: callback_url: URL to POST callbacks to (None disables callbacks) session_id: Unique session identifier for routing @@ -144,157 +128,127 @@ def __init__( self.timeout = timeout self.security = CallbackSecurity(secret) if callback_url else None self.invocation_count = 0 - + # Statistics - self.stats = { - 'total_invocations': 0, - 'successful_invocations': 0, - 'failed_invocations': 0, - 'total_time_ms': 0 - } - + self.stats = {"total_invocations": 0, "successful_invocations": 0, "failed_invocations": 0, "total_time_ms": 0} + def is_enabled(self) -> bool: """Check if callback handler is enabled (has a URL).""" return self.callback_url is not None and self.security is not None - + def invoke(self, callback_name: str, callback_id: int, args: Dict[str, Any]) -> Any: """ Invoke a callback with the given arguments. - + This method makes a synchronous HTTP request and waits for the response. All callbacks are handled the same way regardless of GI scope: - Makes blocking HTTP POST to callback URL - Waits for response - Returns the result (may be None if callback has no return value) - + The GI scope (call/async/notified/forever) only affects WHEN the callback is invoked relative to the caller, not HOW we handle the HTTP request. - + Args: callback_name: Name of the callback being invoked callback_id: Callback ID for correlation tracking (thread affinity) args: Dictionary of arguments (already JSON-serializable from _convert_callback_args) - + Returns: The return value from the callback, or None if no return value """ if not self.is_enabled(): logger.debug(f"Callback {callback_name} skipped (handler disabled)") return None - + self.invocation_count += 1 - + payload = { - 'sessionId': self.session_id, - 'callbackName': callback_name, - 'args': args, # Use args dict directly, no serialization needed - 'invocationNumber': self.invocation_count, - 'timestamp': datetime.now(timezone.utc).isoformat(), - 'correlationId': str(callback_id) # For thread affinity tracking + "sessionId": self.session_id, + "callbackName": callback_name, + "args": args, # Use args dict directly, no serialization needed + "invocationNumber": self.invocation_count, + "timestamp": datetime.now(timezone.utc).isoformat(), + "correlationId": str(callback_id), # For thread affinity tracking } - + response_data = self._post_callback(payload, expect_response=True) - + if response_data: - return response_data.get('return') # No deserialization needed - + return response_data.get("return") # No deserialization needed + return None - - def _post_callback( - self, - payload: Dict[str, Any], - expect_response: bool = False - ) -> Optional[Dict[str, Any]]: + + def _post_callback(self, payload: Dict[str, Any], expect_response: bool = False) -> Optional[Dict[str, Any]]: """ Make HTTP POST request to callback URL. - + Args: payload: Dictionary to send as JSON body expect_response: Whether to parse and return response JSON - + Returns: Response JSON if expect_response=True, otherwise None """ if not self.callback_url or not self.security: return None - + headers = self.security.create_headers(payload) - - import time + start_time = time.time() - + try: - self.stats['total_invocations'] += 1 - + self.stats["total_invocations"] += 1 + logger.debug(f"Posting callback to {self.callback_url} with payload: {payload}") - - response = requests.post( - self.callback_url, - json=payload, - headers=headers, - timeout=self.timeout - ) - + + response = requests.post(self.callback_url, json=payload, headers=headers, timeout=self.timeout) + elapsed_ms = (time.time() - start_time) * 1000 - self.stats['total_time_ms'] += elapsed_ms - + self.stats["total_time_ms"] += elapsed_ms + response.raise_for_status() - - self.stats['successful_invocations'] += 1 - + + self.stats["successful_invocations"] += 1 + logger.debug( - f"Callback posted successfully ({elapsed_ms:.2f}ms): " - f"{payload.get('callbackName', 'unknown')}" + f"Callback posted successfully ({elapsed_ms:.2f}ms): " f"{payload.get('callbackName', 'unknown')}" ) - + if expect_response: return response.json() - + return None - + except requests.exceptions.Timeout: - logger.error( - f"Callback timeout after {self.timeout}s: " - f"{payload.get('callbackName', 'unknown')}" - ) - self.stats['failed_invocations'] += 1 + logger.error(f"Callback timeout after {self.timeout}s: " f"{payload.get('callbackName', 'unknown')}") + self.stats["failed_invocations"] += 1 return None - + except requests.exceptions.RequestException as e: - logger.error( - f"Callback request failed: {e} " - f"({payload.get('callbackName', 'unknown')})" - ) - self.stats['failed_invocations'] += 1 + logger.error(f"Callback request failed: {e} " f"({payload.get('callbackName', 'unknown')})") + self.stats["failed_invocations"] += 1 return None - + except Exception as e: - logger.error( - f"Unexpected error in callback: {e} " - f"({payload.get('callbackName', 'unknown')})" - ) - self.stats['failed_invocations'] += 1 + logger.error(f"Unexpected error in callback: {e} " f"({payload.get('callbackName', 'unknown')})") + self.stats["failed_invocations"] += 1 return None - + def get_stats(self) -> Dict[str, Any]: """ Get statistics about callback invocations. - + Returns: Dictionary with statistics """ stats = self.stats.copy() - - if stats['total_invocations'] > 0: - stats['success_rate'] = ( - stats['successful_invocations'] / stats['total_invocations'] - ) - stats['avg_time_ms'] = ( - stats['total_time_ms'] / stats['total_invocations'] - ) + + if stats["total_invocations"] > 0: + stats["success_rate"] = stats["successful_invocations"] / stats["total_invocations"] + stats["avg_time_ms"] = stats["total_time_ms"] / stats["total_invocations"] else: - stats['success_rate'] = 0.0 - stats['avg_time_ms'] = 0.0 - - return stats + stats["success_rate"] = 0.0 + stats["avg_time_ms"] = 0.0 + return stats diff --git a/girest/girest/generator.py b/girest/girest/generator.py index 2cb68e0..1b78325 100644 --- a/girest/girest/generator.py +++ b/girest/girest/generator.py @@ -1,7 +1,8 @@ -import os import logging -from typing import List, Dict, Any, Optional, Set -from jinja2 import Environment, FileSystemLoader, Template +import os +from typing import Any, Dict, List, Optional, Set + +from jinja2 import Environment, FileSystemLoader from jinja2.exceptions import TemplateNotFound try: @@ -15,12 +16,13 @@ class Info: """Base class for all OpenAPI schema objects with dependency management.""" + info_type = None - def __init__(self, generator: 'Generator', schema_section: Dict[str, Any], parent: Optional['Info'] = None): + def __init__(self, generator: "Generator", schema_section: Dict[str, Any], parent: Optional["Info"] = None): """ Initialize the Info object. - + Args: generator: The generator instance schema_section: The section of the OpenAPI schema that belongs to this object @@ -30,11 +32,11 @@ def __init__(self, generator: 'Generator', schema_section: Dict[str, Any], paren self.schema_section = schema_section self.parent = parent self.dependencies: Set[str] = set() - + def add_dependency(self, dependency: str): """ Add a dependency and propagate it to the parent if it exists. - + Args: dependency: The name of the dependency to add """ @@ -59,19 +61,19 @@ def valid_name(self) -> str: def generate(self) -> str: """Generate the code based on the template.""" # Try specific template first, then fallback to general - template_name = f'{self.info_type}_{self.id}.ts.j2' + template_name = f"{self.info_type}_{self.id}.ts.j2" try: template = self.generator.jinja_env.get_template(template_name) except TemplateNotFound: - template = self.generator.jinja_env.get_template(f'{self.info_type}.ts.j2') + template = self.generator.jinja_env.get_template(f"{self.info_type}.ts.j2") return template.render(**{self.info_type: self}) - + class Generator: def __init__(self, openapi_schema: Dict[str, Any], host: str = "localhost", port: int = 9000, base_path: str = ""): """ Initialize the generator with an OpenAPI schema. - + Args: openapi_schema: The OpenAPI schema dictionary from GIRest host: Host for REST API calls (default: 'localhost') @@ -86,17 +88,13 @@ def __init__(self, openapi_schema: Dict[str, Any], host: str = "localhost", port self.port = port self.base_path = base_path self.base_url = f"http://{host}:{port}{base_path}" - + # Cache for Schema objects to avoid recreating them self.schema_objects_cache: Dict[str, "Schema"] = {} - + # Setup Jinja2 environment using template directory from subclass template_dir = self.get_template_dir() - self.jinja_env = Environment( - loader=FileSystemLoader(template_dir), - trim_blocks=True, - lstrip_blocks=True - ) + self.jinja_env = Environment(loader=FileSystemLoader(template_dir), trim_blocks=True, lstrip_blocks=True) def add_schema(self, schema: "Schema"): self.schema_objects_cache[schema.name] = schema @@ -105,11 +103,11 @@ def get_template_dir(self) -> str: """Get the template directory path. Must be implemented by subclasses.""" raise NotImplementedError("Subclasses must implement get_template_dir() method") - def get_valid_name(self, info: 'Info') -> str: + def get_valid_name(self, info: "Info") -> str: """ Get a safe name for an Info object. Base implementation just returns the current name. Subclasses can override to handle language-specific naming conflicts. - + Args: info: The Info object requesting a name @@ -118,7 +116,7 @@ def get_valid_name(self, info: 'Info') -> str: """ return info.name - def lang_type(self, t: 'Type') -> str: + def lang_type(self, t: "Type") -> str: raise NotImplementedError("Subclasses must implement the lang_type() method") def find_schema(self, name: str) -> Optional[Dict[str, Any]]: @@ -132,18 +130,18 @@ def get_schema(self, name: str) -> "Schema": if schema_def: schema_obj = Schema.create_schema(name, schema_def, self, None) self.add_schema(schema_obj) - + return self.schema_objects_cache[name] - def get_methods_for_schema(self, schema: 'Schema') -> List['Method']: + def get_methods_for_schema(self, schema: "Schema") -> List["Method"]: """Get all Method objects for a specific schema based on tag matching. - + Takes a Schema object, finds all paths that have operations tagged with the schema name, and returns a list of Method objects created from those operations. - + Args: schema: The Schema object to find operations for - + Returns: List of Method objects created from matching operations """ @@ -155,24 +153,24 @@ def get_methods_for_schema(self, schema: 'Schema') -> List['Method']: for method, operation in path_operations.items(): if method.lower() not in ["get", "post", "put", "delete", "patch"]: continue - + tags = operation.get("tags", []) if tags: if tags[0] == schema.name: path_matches = True break - + if path_matches: - # Create Method object with operation dict, path, and http_method directly - method_obj = Method(operation, path, method, schema, self) - methods.append(method_obj) - + # Create Method object with operation dict, path, and http_method directly + method_obj = Method(operation, path, method, schema, self) + methods.append(method_obj) + return methods def _create_namespace_schemas(self): """Create Namespace schema objects for tags that don't have corresponding component schemas. - - Searches all paths to find operations tagged with names that don't correspond to + + Searches all paths to find operations tagged with names that don't correspond to component schemas, and creates Namespace schema objects to hold those operations. """ # Find all unique tags from operations @@ -181,31 +179,31 @@ def _create_namespace_schemas(self): for method, operation in path_operations.items(): if method.lower() not in ["get", "post", "put", "delete", "patch"]: continue - + tags = operation.get("tags", []) if tags: operation_tags.add(tags[0]) # Use first tag as primary - + # Find tags that don't correspond to component schemas for tag in operation_tags: if tag not in self.schemas: # Create a Namespace schema with None schema definition schema = Namespace(tag, self) self.add_schema(schema) - + def _get_callback_mode(self) -> str: """ Get the callback mode from the OpenAPI schema info. - + The callback mode is specified in the schema's info section via the x-girest-callback-mode vendor extension. This is set by GIRest when generating the schema based on the --sse-only flag. - + Returns: str: "sse" for SSE mode, "url" for URL-based callbacks, defaults to "sse" """ return self.schema.get("info", {}).get("x-girest-callback-mode", "sse") - + def generate(self) -> str: """Generate complete TypeScript bindings.""" title = self.schema.get("info", {}).get("title", "API") @@ -218,13 +216,13 @@ def generate(self) -> str: self.add_schema(schema) # Now the tags without schemas self._create_namespace_schemas() - + # Get callback mode from schema callback_mode = self._get_callback_mode() - sse_mode = (callback_mode == "sse") + sse_mode = callback_mode == "sse" # Generate main file - main_template = self.jinja_env.get_template('main.ts.j2') + main_template = self.jinja_env.get_template("main.ts.j2") return main_template.render( title=title, version=version, @@ -236,11 +234,13 @@ def generate(self) -> str: sse_mode=sse_mode, ) + class Type(Info): """Represents a type with its string representation and metadata.""" - + info_type = "type" - def __init__(self, schema: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + + def __init__(self, schema: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(generator, schema, parent) self._ref_schema = None self._component_name = None @@ -290,9 +290,10 @@ def subtype(self) -> "Type": class Param(Info): """Represents a method parameter with all its metadata.""" - + info_type = "param" - def __init__(self, param_def: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + + def __init__(self, param_def: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(generator, param_def, parent) self._ref_callback = None self.type = Type(param_def.get("schema", {}), generator, self) @@ -324,7 +325,7 @@ def explode(self) -> bool: @property def description(self) -> str: return self.schema_section.get("description", "") - + @property def is_callback(self) -> bool: """Check if this parameter represents a callback (non-SSE mode).""" @@ -357,9 +358,10 @@ def callback_style(self) -> str: class ReturnParam(Info): """Represents a return parameter with its schema information specific to return values.""" - + info_type = "return_param" - def __init__(self, name: str, generator: 'Generator', schema: Dict[str, Any], parent: Optional['Info'] = None): + + def __init__(self, name: str, generator: "Generator", schema: Dict[str, Any], parent: Optional["Info"] = None): super().__init__(generator, schema, parent) self._name = name self._type = Type(schema, generator, self) @@ -373,7 +375,7 @@ def name(self): return self._name @property - def type(self) -> 'Type': + def type(self) -> "Type": return self._type @property @@ -387,25 +389,25 @@ def can_be_null(self) -> str: @property def description(self) -> str: return self.schema_section.get("description", "") - + @property def is_callback(self) -> bool: return "x-gi-callback" in self.schema_section @property - def callback(self) -> 'Callback': + def callback(self) -> "Callback": return self._callback def generate(self) -> str: # We should try "return_param_GObjectObject.ts.j2", "return_param_object.ts.j2" or "return_param.ts.j2" template_names = [ - f'{self.info_type}_{self.type.lang_type}.ts.j2', - f'{self.info_type}_{self.type.type}.ts.j2', + f"{self.info_type}_{self.type.lang_type}.ts.j2", + f"{self.info_type}_{self.type.type}.ts.j2", ] if self.type.is_ref: - template_names.append(f'{self.info_type}_{self.type.ref_schema.info_type}.ts.j2') + template_names.append(f"{self.info_type}_{self.type.ref_schema.info_type}.ts.j2") - template_names.append(f'{self.info_type}.ts.j2') + template_names.append(f"{self.info_type}.ts.j2") for tn in template_names: try: template = self.generator.jinja_env.get_template(tn) @@ -414,17 +416,12 @@ def generate(self) -> str: pass - class Schema(Info): """Base class for all schema types.""" - + info_type = "schema" - def __init__( - self, - name: str, - schema_def: Dict[str, Any], - generator: 'Generator', - parent: Optional['Info'] = None): + + def __init__(self, name: str, schema_def: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(generator, schema_def, parent) self._name = name self.parse_schema() @@ -437,11 +434,13 @@ def parse_schema(self): pass @classmethod - def create_schema(cls, name: str, schema_def: Optional[Dict[str, Any]], generator: 'Generator', parent: Optional['Info'] = None) -> 'Schema': + def create_schema( + cls, name: str, schema_def: Optional[Dict[str, Any]], generator: "Generator", parent: Optional["Info"] = None + ) -> "Schema": """Factory method to create appropriate schema type.""" if schema_def is None: return Namespace(name, generator) - + gi_type = schema_def.get("x-gi-type", "") if not gi_type: schema_type = schema_def.get("type") @@ -462,23 +461,21 @@ def create_schema(cls, name: str, schema_def: Optional[Dict[str, Any]], generato return Object(name, schema_def, generator, parent) else: return Schema(name, schema_def, generator, parent) - + class Enum(Schema): """Represents an GObject Enumeration schema.""" - + info_type = "enum" - def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + + def __init__(self, name: str, schema_def: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(name, schema_def, generator, parent) self._methods: List[Method] = self.generator.get_methods_for_schema(self) @property def values(self): return [ - { - "const_name": value.upper().replace("-", "_").replace(".", "_"), - "value": value - } + {"const_name": value.upper().replace("-", "_").replace(".", "_"), "value": value} for value in self.schema_section.get("enum", []) ] @@ -497,14 +494,16 @@ def methods(self) -> List["Method"]: class Flags(Enum): """Represents a GObject flags schema (bitfield enum).""" - + info_type = "flags" - + class Field(Schema): """Represents a GObject Callback parameter or generic field""" + info_type = "field" - def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator', parent: 'Info'): + + def __init__(self, name: str, schema_def: Dict[str, Any], generator: "Generator", parent: "Info"): super().__init__(name, schema_def, generator, parent) self.type = Type(schema_def, generator, self) @@ -519,9 +518,10 @@ def can_be_null(self) -> str: class Callback(Schema): """Represents a GObject Callback function schema.""" - + info_type = "callback" - def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + + def __init__(self, name: str, schema_def: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(name, schema_def, generator, parent) self._parameters: List[Param] = [] self._return_param = None @@ -532,7 +532,7 @@ def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator' # that don't have this flag if pv.get("x-gi-is-return", False): self._return_param = ReturnParam(pname, self.generator, pv, self) - elif not pname in ['sessionId', 'callbackName', 'args', 'invocationNumber', 'timestamp']: + elif pname not in ["sessionId", "callbackName", "args", "invocationNumber", "timestamp"]: # Skip non-SSE mode metadata properties self._parameters.append(Field(pname, pv, generator, self)) @@ -553,9 +553,10 @@ def is_void(self) -> bool: class Struct(Schema): """Represents a GObject Struct schema.""" - + info_type = "struct" - def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + + def __init__(self, name: str, schema_def: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(name, schema_def, generator, parent) self._methods: List[Method] = self.generator.get_methods_for_schema(self) self._parent_schema = None @@ -610,12 +611,12 @@ def parent_schema(self) -> "Object": return self._parent_schema - class Object(Schema): """Represents an object schema with inheritance.""" - + info_type = "object" - def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + + def __init__(self, name: str, schema_def: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): super().__init__(name, schema_def, generator, parent) self._methods: List[Method] = self.generator.get_methods_for_schema(self) self._parent_schema = None @@ -629,7 +630,7 @@ def __init__(self, name: str, schema_def: Dict[str, Any], generator: 'Generator' has_destructor_or_copy = any(method.is_destructor or method.is_copy for method in self._methods) if not self._parent_schema and not has_destructor_or_copy: logger.warning(f"Base class '{self.name}' is missing ref (copy), unref (destructor) method(s)") - + def _extract_parent_name(self) -> Optional[str]: """Extract parent class name from allOf structure.""" if "allOf" in self.schema_section: @@ -706,12 +707,13 @@ def type_name(self) -> str: class Namespace(Schema): """Represents a namespace schema for holding static methods only.""" - + info_type = "namespace" - def __init__(self, name: str, generator: 'Generator'): + + def __init__(self, name: str, generator: "Generator"): super().__init__(name, None, generator, None) - self._methods: List['Method'] = self.generator.get_methods_for_schema(self) - + self._methods: List["Method"] = self.generator.get_methods_for_schema(self) + @property def methods(self): return self._methods @@ -719,27 +721,26 @@ def methods(self): class Alias(Schema): """Represents an alias schema. Like Pointer.""" - + info_type = "alias" - + # TODO this is wrong. def generate(self) -> str: # For basic types (string, number, boolean), use alias template schema_type = self.schema_section.get("type") if schema_type in ["string", "number", "integer", "boolean"]: - template = self.generator.jinja_env.get_template('alias.ts.j2') - + template = self.generator.jinja_env.get_template("alias.ts.j2") + # Use the Type class to get the TypeScript type type_obj = Type(self.schema_section, self.generator, self) typescript_type = type_obj.lang_type - - return template.render(**{self.info_type: self, 'typescript_type': typescript_type}) - + + return template.render(**{self.info_type: self, "typescript_type": typescript_type}) class Interface(Schema): """Represents a schema of object type, but not x-gi-type, like Event.""" - + info_type = "interface" def parse_schema(self): @@ -747,32 +748,31 @@ def parse_schema(self): # Prepare basic interface data properties = self.schema_section.get("properties", {}) if self.schema_section else {} required = self.schema_section.get("required", []) if self.schema_section else [] - + self.has_parent = False # TODO: Check for inheritance self.parent = None # TODO: Extract parent if exists self.properties = [] - + # Create property objects using the Type class for prop_name, prop_schema in properties.items(): prop_type = Type(prop_schema, self.generator, self) - self.properties.append({ - "name": prop_name, - "optional": "" if prop_name in required else "?", - "type": prop_type.lang_type - }) + self.properties.append( + {"name": prop_name, "optional": "" if prop_name in required else "?", "type": prop_type.lang_type} + ) # TODO this is wrong. def generate(self) -> str: """Generate TypeScript code. Base implementation for unknown types.""" - template = self.generator.jinja_env.get_template('interface.ts.j2') + template = self.generator.jinja_env.get_template("interface.ts.j2") return template.render(**{self.info_type: self}) - + + class Return(Info): """Represents a method return type with its schema information.""" info_type = "return" - def __init__(self, method_data: Dict[str, Any], generator: 'Generator', parent: Optional['Info'] = None): + def __init__(self, method_data: Dict[str, Any], generator: "Generator", parent: Optional["Info"] = None): # Extract the return schema from method_data responses = method_data.get("responses", {}) return_schema = {} @@ -780,13 +780,13 @@ def __init__(self, method_data: Dict[str, Any], generator: 'Generator', parent: content = responses["200"].get("content", {}) app_json = content.get("application/json", {}) return_schema = app_json.get("schema", {}) - + super().__init__(generator, return_schema, parent) self.method_data = method_data self.properties = {} self._return_params: List[ReturnParam] = [] self._parse_returns() - + def _parse_returns(self): """Parse the return types from method responses.""" if self.schema_section: @@ -805,24 +805,21 @@ def is_void(self) -> bool: return not self._return_params def generate(self) -> str: - template = self.generator.jinja_env.get_template('return.ts.j2') + template = self.generator.jinja_env.get_template("return.ts.j2") return template.render(**{self.info_type: self}) class Method(Info): """Represents a method of a schema.""" - + info_type = "method" + def __init__( - self, - operation_dict: Dict[str, Any], - path: str, - http_method: str, - parent_schema: Schema, - generator: 'Generator'): + self, operation_dict: Dict[str, Any], path: str, http_method: str, parent_schema: Schema, generator: "Generator" + ): """ Initialize a Method object. - + Args: operation_dict: The OpenAPI operation dictionary path: The path for this operation @@ -835,14 +832,14 @@ def __init__( self.path = path self.http_method = http_method self.return_obj = Return(operation_dict, generator, self) - + # Parse parameters using the Param class self.parameters: List[Param] = [] raw_parameters = self.operation_dict.get("parameters", []) for param_def in raw_parameters: param = Param(param_def, self.generator, self) self.parameters.append(param) - + # Parse request body properties for POST/PUT methods self.body_properties: List[Param] = [] request_body = self.operation_dict.get("requestBody", {}) @@ -852,7 +849,7 @@ def __init__( schema = json_content.get("schema", {}) properties = schema.get("properties", {}) required_fields = schema.get("required", []) - + for prop_name, prop_schema in properties.items(): # Create a parameter-like object for each body property # Copy x-gi-* attributes from prop_schema to the top level for Param to find them @@ -860,16 +857,16 @@ def __init__( "name": prop_name, "in": "body", "required": prop_name in required_fields, - "schema": prop_schema + "schema": prop_schema, } # Copy x-gi-callback and other x-gi-* attributes to param_def level for key in prop_schema: if key.startswith("x-gi-"): param_def[key] = prop_schema[key] - + param = Param(param_def, self.generator, self) self.body_properties.append(param) - + @property def params(self) -> List[Param]: """Get all parameters for this method.""" @@ -884,7 +881,7 @@ def query_params(self) -> List[Param]: def path_params(self) -> List[Param]: """Get only path parameters for this method.""" return [p for p in self.parameters if p.location == "path"] - + @property def parsed_operation_id(self): operation_id = self.schema_section.get("operationId", "") @@ -906,8 +903,7 @@ def id(self) -> str: if operation: name += f"_{operation}" name += f"_{method_name}" - return name - + return name @property def name(self) -> str: @@ -945,22 +941,22 @@ def is_namespace_function(self) -> bool: return False @property - def callback_params(self) -> List['ReturnParam']: + def callback_params(self) -> List["ReturnParam"]: return [rp for rp in self.return_obj.return_params if rp.is_callback] - + @property - def callback_url_params(self) -> List['Param']: + def callback_url_params(self) -> List["Param"]: """Get callback URL parameters (non-SSE mode).""" # Check both query params and body properties for callbacks query_callbacks = [p for p in self.query_params if p.is_callback] body_callbacks = [p for p in self.body_properties if p.is_callback] return query_callbacks + body_callbacks - + @property - def header_params(self) -> List['Param']: + def header_params(self) -> List["Param"]: """Get header parameters (typically for non-SSE mode callbacks).""" return [p for p in self.parameters if p.location == "header"] - + @property def uses_sse_callbacks(self) -> bool: """Determine if this method uses SSE-style callbacks (returns callback ID) or URL-based callbacks.""" @@ -971,20 +967,20 @@ def uses_sse_callbacks(self) -> bool: def generate(self) -> str: """Generate the code based on the template, selecting HTTP method-specific templates.""" # Try specific template first (e.g., method_GstBus_connect_sync_message.ts.j2) - template_name = f'{self.info_type}_{self.id}.ts.j2' + template_name = f"{self.info_type}_{self.id}.ts.j2" try: template = self.generator.jinja_env.get_template(template_name) except TemplateNotFound: # Try HTTP method-specific template (e.g., method_get.ts.j2, method_post.ts.j2) http_method_lower = self.http_method.lower() try: - template = self.generator.jinja_env.get_template(f'{self.info_type}_{http_method_lower}.ts.j2') + template = self.generator.jinja_env.get_template(f"{self.info_type}_{http_method_lower}.ts.j2") except TemplateNotFound: # Fallback to generic method template - template = self.generator.jinja_env.get_template(f'{self.info_type}.ts.j2') + template = self.generator.jinja_env.get_template(f"{self.info_type}.ts.j2") return template.render(**{self.info_type: self}) - def is_equal(self, other: 'Method') -> bool: + def is_equal(self, other: "Method") -> bool: # Check the name if self.name != other.name: return False @@ -1011,34 +1007,81 @@ def is_equal(self, other: 'Method') -> bool: Refactored to use a type-oriented approach similar to main.py where each x-gi-type (object, struct, enum, callback, etc.) is handled by dedicated methods. """ + + class TypeScriptGenerator(Generator): """Generates TypeScript bindings from OpenAPI schema using Jinja2 templates.""" - + # Reserved keywords in TypeScript/JavaScript RESERVED_KEYWORDS = { - "function", "var", "let", "const", "class", "interface", "enum", "type", - "namespace", "module", "import", "export", "default", "async", "await", - "break", "case", "catch", "continue", "debugger", "delete", "do", "else", - "finally", "for", "if", "in", "instanceof", "new", "return", "switch", - "this", "throw", "try", "typeof", "void", "while", "with", "yield", - "package", "implements", "private", "public", "protected", "static", - "eval", "arguments", + "function", + "var", + "let", + "const", + "class", + "interface", + "enum", + "type", + "namespace", + "module", + "import", + "export", + "default", + "async", + "await", + "break", + "case", + "catch", + "continue", + "debugger", + "delete", + "do", + "else", + "finally", + "for", + "if", + "in", + "instanceof", + "new", + "return", + "switch", + "this", + "throw", + "try", + "typeof", + "void", + "while", + "with", + "yield", + "package", + "implements", + "private", + "public", + "protected", + "static", + "eval", + "arguments", # Common variable names that might conflict - "data", "response", "error", "result", "value", "url" + "data", + "response", + "error", + "result", + "value", + "url", } - + def __init__(self, openapi_schema: Dict[str, Any], host: str = "localhost", port: int = 9000, base_path: str = ""): super().__init__(openapi_schema, host, port, base_path) # Base class already sets up jinja_env - no need to override unless specific customization needed def get_template_dir(self) -> str: """Get the template directory for TypeScript generation.""" - return os.path.join(os.path.dirname(__file__), 'templates') + return os.path.join(os.path.dirname(__file__), "templates") - def get_valid_name(self, info: 'Info') -> str: + def get_valid_name(self, info: "Info") -> str: """ Get a safe name for TypeScript, handling reserved keywords and method conflicts. - + Args: info: The Info object requesting a name @@ -1048,18 +1091,18 @@ def get_valid_name(self, info: 'Info') -> str: # The name of an Enum might vary, or either Enum or EnumValue if isinstance(info, Enum): if info.methods: - return f'{info.name}Value' + return f"{info.name}Value" else: - return f'{info.name}' + return f"{info.name}" elif isinstance(info, Method): # For methods, handle inheritance conflicts method = info schema = method.parent - + # Get all method names from parent classes if isinstance(schema, Object): parent_methods = self._get_parent_methods(schema.parent_schema) - + # Check for conflicts and add suffix if needed for m in parent_methods: if info.name == m.name and not info.is_equal(m): @@ -1070,12 +1113,12 @@ def get_valid_name(self, info: 'Info') -> str: suffix += 1 return f"{info.name}_{suffix}" return info.name - + # Check for reserved keywords - but skip for constructor methods # Constructor methods should keep their original names (like "new") if not method.is_constructor and info.name in self.RESERVED_KEYWORDS: return f"{info.name}_" - + return info.name else: # Handle reserved keywords for non-method objects @@ -1083,7 +1126,7 @@ def get_valid_name(self, info: 'Info') -> str: return f"{info.name}_" return info.name - def lang_type(self, t: 'Type') -> str: + def lang_type(self, t: "Type") -> str: """Convert OpenAPI basic types to TypeScript types.""" if t.is_ref: return t.ref_schema.valid_name @@ -1099,7 +1142,7 @@ def lang_type(self, t: 'Type') -> str: } return type_mapping.get(t.type, "any") - def _get_parent_methods(self, obj: 'Schema') -> Set['Method']: + def _get_parent_methods(self, obj: "Schema") -> Set["Method"]: """Get all method names from parent classes in the inheritance chain.""" parent_methods = set() @@ -1116,7 +1159,7 @@ def _get_parent_methods(self, obj: 'Schema') -> Set['Method']: parent_methods.update(self._get_parent_methods(obj.parent_schema)) return parent_methods - + def _safe_property_name(self, name: str) -> str: """Convert a schema property name to a safe TypeScript identifier.""" if name in self.RESERVED_KEYWORDS: diff --git a/girest/girest/main.py b/girest/girest/main.py index f80947a..95d8620 100644 --- a/girest/girest/main.py +++ b/girest/girest/main.py @@ -1,26 +1,17 @@ import logging import gi + gi.require_version("GIRepository", "2.0") -from gi.repository import GIRepository -from apispec import APISpec +from apispec import APISpec # noqa: E402 +from gi.repository import GIRepository # noqa: E402 logger = logging.getLogger("girest") -class GIRest(): - pointer_schema = { - "type": "string", - "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$" - } - event_schema = { - "type": "object", - "required": ["data"], - "properties": { - "data": { - "type": "string" - } - } - } + +class GIRest: + pointer_schema = {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"} + event_schema = {"type": "object", "required": ["data"], "properties": {"data": {"type": "string"}}} def __init__(self, ns, ns_version, sse_only=False): self.ns = ns @@ -34,7 +25,7 @@ def __init__(self, ns, ns_version, sse_only=False): openapi_version="3.0.2", info={ "description": "API schema autogenerated by giREST", - "x-girest-callback-mode": "sse" if sse_only else "url" + "x-girest-callback-mode": "sse" if sse_only else "url", }, ) # Include the Pointer and Event definitions @@ -43,18 +34,18 @@ def __init__(self, ns, ns_version, sse_only=False): # Load the corresponding Gir file self.repo = GIRepository.Repository() self.repo.require(ns, ns_version, 0) - + # Discover and load namespace dependencies self.namespaces = [] # Start with the dependencies dependencies = self.repo.get_immediate_dependencies(ns) for dep in dependencies: - dep_ns, dep_version = dep.split('-', 1) + dep_ns, dep_version = dep.split("-", 1) self.repo.require(dep_ns, dep_version, 0) self.namespaces.append((dep_ns, dep_version)) self.namespaces.append((ns, ns_version)) # Generate the generic callback endpoint - # TODO define all callbacks as events as defined in + # TODO define all callbacks as events as defined in # https://spec.openapis.org/oas/v3.2.0.html#server-sent-event-streams operation = { "summary": "Callback emitters", @@ -64,27 +55,19 @@ def __init__(self, ns, ns_version, sse_only=False): "responses": { "200": { "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "$ref": "#/components/schemas/Event" - } - } - } + "content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/Event"}}}, } }, } - self.spec.path(path="/GIRest/callbacks", operations={ - "get": operation - }) + self.spec.path(path="/GIRest/callbacks", operations={"get": operation}) def _get_container_element_type_schema(self, container_type_info): """ Get the element type schema from a container type (GList, GSList, etc.). - + Args: container_type_info: GIRepository type info for the container - + Returns: dict: Element type schema or None if not available """ @@ -92,32 +75,34 @@ def _get_container_element_type_schema(self, container_type_info): element_type_info = GIRepository.type_info_get_param_type(container_type_info, 0) if element_type_info: return self._type_to_schema(element_type_info) - + return None def _add_non_sse_parameters(self, operation): # Add header parameters for callback authentication - operation['parameters'].extend([ - { - 'name': 'session-id', - 'in': 'header', - 'required': True, - 'schema': {'type': 'string'}, - 'description': 'Session identifier for callback routing' - }, - { - 'name': 'callback-secret', - 'in': 'header', - 'required': True, - 'schema': {'type': 'string'}, - 'description': 'Shared secret for callback HMAC signatures' - } - ]) + operation["parameters"].extend( + [ + { + "name": "session-id", + "in": "header", + "required": True, + "schema": {"type": "string"}, + "description": "Session identifier for callback routing", + }, + { + "name": "callback-secret", + "in": "header", + "required": True, + "schema": {"type": "string"}, + "description": "Shared secret for callback HMAC signatures", + }, + ] + ) def _type_to_schema(self, t): """Convert GIRepository type to OpenAPI schema""" tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(t)) - + if tag == "void" and GIRepository.type_info_is_pointer(t): # Check if this is a void pointer (gpointer) # In GIRepository, gpointer is represented as void type with is_pointer=True @@ -132,15 +117,15 @@ def _type_to_schema(self, t): list_info = repo.find_by_name("GLib", "List") if list_info: self._generate_struct(list_info) - + # Get element type schema element_type_schema = self._get_container_element_type_schema(t) schema = {"$ref": "#/components/schemas/GLibList"} - + # Add vendor-specific tag if element type is available if element_type_schema: schema["x-gi-element-type"] = element_type_schema - + return schema elif tag == "gslist": # Handle GSList type - generate GLibSList schema if needed @@ -148,30 +133,30 @@ def _type_to_schema(self, t): slist_info = repo.find_by_name("GLib", "SList") if slist_info: self._generate_struct(slist_info) - + # Get element type schema element_type_schema = self._get_container_element_type_schema(t) schema = {"$ref": "#/components/schemas/GLibSList"} - + # Add vendor-specific tag if element type is available if element_type_schema: schema["x-gi-element-type"] = element_type_schema - + return schema elif tag == "array": # Handle array type - only export C arrays for now array_type = GIRepository.type_info_get_array_type(t) - + # Only handle C arrays (GI_ARRAY_TYPE_C) if array_type != GIRepository.ArrayType.C: # Skip other array types (GArray, PtrArray, ByteArray, etc.) return None - + # Get element type schema element_type_schema = self._get_container_element_type_schema(t) - + schema = {"type": "array"} - + # Add items schema if element type is available if element_type_schema: schema["items"] = element_type_schema @@ -180,10 +165,10 @@ def _type_to_schema(self, t): else: # Default to any type if element type is not available schema["items"] = {} - + # Add vendor-specific tag for zero-terminated arrays schema["x-gi-array-null-terminated"] = GIRepository.type_info_is_zero_terminated(t) - + return schema elif tag == "interface": # Check if it's an interface type @@ -217,7 +202,7 @@ def _type_to_schema(self, t): full_name = self._generate_callback(interface) # Return reference to the callback schema return {"$ref": f"#/components/schemas/{full_name}"} - + # Map GIRepository type tags to OpenAPI types # Note: OpenAPI 3.0 doesn't distinguish between signed and unsigned integers # at the schema level - both use 'integer' type. Format specifications (int32, int64) @@ -241,10 +226,9 @@ def _type_to_schema(self, t): "gdouble": {"type": "number", "format": "double"}, "gsize": {"type": "number", "format": "int64"}, "gpointer": {"$ref": "#/components/schemas/Pointer"}, - "GType": {"type": "string"}, # FIXME beware ot this + "GType": {"type": "string"}, # FIXME beware ot this } - - + ret = type_map.get(tag, {"$ref": "#/components/schemas/Pointer"}) if not ret: logger.warning(f"Unknown type tag: {tag}") @@ -260,7 +244,7 @@ def _transfer_to_str(self, transfer): else: transfer_str = "none" return transfer_str - + def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=False, is_copy=False): api = f"/{bim.get_namespace()}" if bi: @@ -268,30 +252,32 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F if GIRepository.function_info_get_flags(bim) & 1: api += "/{self}" api += f"/{bim.get_name()}" - + # Handle the parameters params = [] response_props = {} - + # Add self parameter for methods if GIRepository.function_info_get_flags(bim) & 1: - params.append({ - "name": "self", - "in": "path", - "required": True, - "schema": {"$ref": f"#/components/schemas/{bi.get_namespace()}{bi.get_name()}"}, - "description": "" - }) - + params.append( + { + "name": "self", + "in": "path", + "required": True, + "schema": {"$ref": f"#/components/schemas/{bi.get_namespace()}{bi.get_name()}"}, + "description": "", + } + ) + # Get number of arguments n_args = GIRepository.callable_info_get_n_args(bim) - + # Track callbacks found in this method method_callbacks = [] - + # First pass: identify which arguments should be skipped skip_indices = set() - + # Check return type for arrays with length parameters return_type = GIRepository.callable_info_get_return_type(bim) return_tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(return_type)) @@ -301,13 +287,13 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F length_idx = GIRepository.type_info_get_array_length(return_type) if length_idx >= 0: skip_indices.add(length_idx) - + # Check all parameters for arrays with length parameters for i in range(n_args): arg = GIRepository.callable_info_get_arg(bim, i) arg_type = GIRepository.arg_info_get_type(arg) tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(arg_type)) - + # Check if this parameter is an array with a length parameter if tag == "array": array_type = GIRepository.type_info_get_array_type(arg_type) @@ -315,7 +301,7 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F length_idx = GIRepository.type_info_get_array_length(arg_type) if length_idx >= 0: skip_indices.add(length_idx) - + # Check if this is a callback if tag == "interface": interface = GIRepository.type_info_get_interface(arg_type) @@ -327,18 +313,18 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F destroy_idx = GIRepository.arg_info_get_destroy(arg) if destroy_idx >= 0: skip_indices.add(destroy_idx) - + # Second pass: process all arguments for i in range(n_args): arg = GIRepository.callable_info_get_arg(bim, i) arg_type = GIRepository.arg_info_get_type(arg) arg_name = arg.get_name() arg_direction = GIRepository.arg_info_get_direction(arg) - + # Skip arguments that are marked as skipped if i in skip_indices: continue - + # Check if this is a callback tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(arg_type)) if tag == "interface": @@ -346,77 +332,86 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F if interface and interface.get_type() == GIRepository.InfoType.CALLBACK: # Generate the callback schema (needed in both modes) full_name = self._generate_callback(interface) - + if self.sse_only: # SSE-only mode: check scope and skip sync callbacks scope = GIRepository.arg_info_get_scope(arg) - is_sync = (scope == GIRepository.ScopeType.CALL) - + is_sync = scope == GIRepository.ScopeType.CALL + if is_sync: # Skip methods with synchronous callbacks in SSE-only mode return - + # For async callbacks in SSE-only mode, add callback ID to response (old behavior) response_props[arg_name] = { "type": "integer", "description": "Callback ID", - "x-gi-callback": f"#/components/schemas/{full_name}" + "x-gi-callback": f"#/components/schemas/{full_name}", } else: # Standard mode: generate full callback specification # Generate OpenAPI callback specification # Pass the arg (ArgInfo) so we can get the scope - callback_param, callbacks_obj, is_sync = self._generate_callback_argument(interface, arg, arg_name) - + callback_param, callbacks_obj, is_sync = self._generate_callback_argument( + interface, arg, arg_name + ) + # Add callback parameter to method params.append(callback_param) - + # Store callback info for later - method_callbacks.append({ - 'name': arg_name, - 'interface': interface, - 'callbacks_obj': callbacks_obj, - 'is_synchronous': is_sync - }) - + method_callbacks.append( + { + "name": arg_name, + "interface": interface, + "callbacks_obj": callbacks_obj, + "is_synchronous": is_sync, + } + ) + continue - + # Handle output parameters - they go in the response if arg_direction == GIRepository.Direction.OUT: # unless the caller allocates and is not a struct or an object - if tag != "interface" or (interface.get_type() in [GIRepository.InfoType.STRUCT, GIRepository.InfoType.OBJECT] and not GIRepository.arg_info_is_caller_allocates): + if tag != "interface" or ( + interface.get_type() in [GIRepository.InfoType.STRUCT, GIRepository.InfoType.OBJECT] + and not GIRepository.arg_info_is_caller_allocates + ): schema = self._type_to_schema(arg_type) if schema: response_props[arg_name] = schema continue - + # Handle input and inout parameters schema = self._type_to_schema(arg_type) if not schema: continue - + # INOUT parameters go in both request and response if arg_direction == GIRepository.Direction.INOUT: response_props[arg_name] = schema - + # Add as query parameter param_schema = schema.copy() may_be_null = GIRepository.arg_info_may_be_null(arg) - + # Get transfer ownership information transfer_str = self._transfer_to_str(GIRepository.arg_info_get_ownership_transfer(arg)) - - params.append({ - "name": arg_name, - "in": "query", - "required": not may_be_null, - "schema": param_schema, - "description": "", - "x-gi-transfer": transfer_str, - "style": "form", - "explode": False - }) - + + params.append( + { + "name": arg_name, + "in": "query", + "required": not may_be_null, + "schema": param_schema, + "description": "", + "x-gi-transfer": transfer_str, + "style": "form", + "explode": False, + } + ) + # Handle the return value return_type = GIRepository.callable_info_get_return_type(bim) return_schema = self._type_to_schema(return_type) @@ -427,56 +422,45 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F response_props["return"] = { **return_schema, "x-gi-transfer": return_transfer_str, - "x-gi-null": GIRepository.callable_info_may_return_null(bim) + "x-gi-null": GIRepository.callable_info_may_return_null(bim), } - + # Build response schema responses = {} if response_props: responses["200"] = { "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": response_props - } - } - } + "content": {"application/json": {"schema": {"type": "object", "properties": response_props}}}, } else: responses["204"] = {"description": "No Content"} # Add Prefer header parameter for async execution - params.append({ - "name": "Prefer", - "in": "header", - "required": False, - "schema": { - "type": "string", - "enum": ["respond-async"] - }, - "description": "Request asynchronous execution. When set to 'respond-async', the server returns 202 Accepted immediately and processes the request in the background." - }) - + params.append( + { + "name": "Prefer", + "in": "header", + "required": False, + "schema": {"type": "string", "enum": ["respond-async"]}, + "description": "Request asynchronous execution. When set to 'respond-async', the server returns 202 Accepted immediately and processes the request in the background.", + } + ) + # Add 202 response for async execution (no content, only header) responses["202"] = { "description": "Accepted - Request is being processed asynchronously", "headers": { "Preference-Applied": { - "schema": { - "type": "string", - "enum": ["respond-async"] - }, - "description": "Indicates that the async preference was honored" + "schema": {"type": "string", "enum": ["respond-async"]}, + "description": "Indicates that the async preference was honored", } - } + }, } - + # Override constructor detection if explicitly passed if not is_constructor: flags = GIRepository.function_info_get_flags(bim) is_constructor = bool(flags & GIRepository.FunctionInfoFlags.IS_CONSTRUCTOR) - + # Build operation definition operation = { "summary": "", @@ -486,16 +470,15 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F "parameters": params, "responses": responses, } - + # Add callbacks if any were found if method_callbacks: callbacks_spec = {} for cb_info in method_callbacks: - callbacks_spec.update(cb_info['callbacks_obj']) - operation['callbacks'] = callbacks_spec + callbacks_spec.update(cb_info["callbacks_obj"]) + operation["callbacks"] = callbacks_spec self._add_non_sse_parameters(operation) - # Only add vendor-specific attributes when they are True if is_constructor: operation["x-gi-constructor"] = True @@ -503,24 +486,22 @@ def _generate_function(self, bim, bi=None, is_constructor=False, is_destructor=F operation["x-gi-destructor"] = True if is_copy: operation["x-gi-copy"] = True - + # Add paths, components, etc. programmatically - self.spec.path(path=api, operations={ - "get": operation - }) + self.spec.path(path=api, operations={"get": operation}) def _generate_object(self, bi): # If we already registered that, skip it full_name = f"{bi.get_namespace()}{bi.get_name()}" if full_name in self.schemas: return - + # Mark as generated early to prevent circular dependencies self.schemas[full_name] = True - + # Generate the type for every parent # In of GObject, use GTypeInstance as parent - if bi.get_name() in ["Object","ParamSpec"] and bi.get_namespace() == "GObject": + if bi.get_name() in ["Object", "ParamSpec"] and bi.get_namespace() == "GObject": parent = self.repo.find_by_name("GObject", "TypeInstance") else: parent = GIRepository.object_info_get_parent(bi) @@ -541,14 +522,14 @@ def _generate_object(self, bi): {"$ref": f"#/components/schemas/{full_parent_name}"}, { "type": "object", - } + }, ], "x-gi-type": "object", "x-gi-namespace": f"{bi.get_namespace()}", "x-gi-name": f"{bi.get_name()}", - "x-gi-class": f"{class_struct.get_namespace()}{class_struct.get_name()}" if class_struct else None - } - ) + "x-gi-class": f"{class_struct.get_namespace()}{class_struct.get_name()}" if class_struct else None, + }, + ) else: self.spec.components.schema( full_name, @@ -561,14 +542,14 @@ def _generate_object(self, bi): "x-gi-type": "object", "x-gi-namespace": f"{bi.get_namespace()}", "x-gi-name": f"{bi.get_name()}", - "x-gi-class": f"{class_struct.get_namespace()}{class_struct.get_name()}" if class_struct else None - } + "x-gi-class": f"{class_struct.get_namespace()}{class_struct.get_name()}" if class_struct else None, + }, ) - + # Get official ref and unref function names from GIRepository API ref_func_name = None unref_func_name = None - + try: ref_func = GIRepository.object_info_get_ref_function(bi) if ref_func: @@ -576,7 +557,7 @@ def _generate_object(self, bi): except AttributeError: # API not available for older GIRepository versions pass - + try: unref_func = GIRepository.object_info_get_unref_function(bi) if unref_func: @@ -584,41 +565,49 @@ def _generate_object(self, bi): except AttributeError: # API not available for older GIRepository versions pass - + # Now the member functions found_unref_func = False found_ref_func = False for i in range(0, GIRepository.object_info_get_n_methods(bi)): bim = GIRepository.object_info_get_method(bi, i) method_name = bim.get_name() - + # Determine if this is a ref or unref method is_copy = False is_destructor = False - + # Check ref function if ref_func_name and method_name == ref_func_name: is_copy = True found_ref_func = True - elif not ref_func_name and method_name in ['ref', 'ref_sink']: - logger.warning(f"Using fallback detection for ref function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}") + elif not ref_func_name and method_name in ["ref", "ref_sink"]: + logger.warning( + f"Using fallback detection for ref function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}" + ) is_copy = True found_ref_func = True - - # Check unref function + + # Check unref function if unref_func_name and method_name == unref_func_name: is_destructor = True found_unref_func = True - elif not unref_func_name and method_name == 'unref': - logger.warning(f"Using fallback detection for unref function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}") + elif not unref_func_name and method_name == "unref": + logger.warning( + f"Using fallback detection for unref function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}" + ) is_destructor = True found_unref_func = True - + self._generate_function(bim, bi, is_copy=is_copy, is_destructor=is_destructor) if not found_ref_func and ref_func_name: - logger.warning(f"Ref function '{ref_func_name}' not found in methods of {bi.get_namespace()}.{bi.get_name()}") + logger.warning( + f"Ref function '{ref_func_name}' not found in methods of {bi.get_namespace()}.{bi.get_name()}" + ) if not found_unref_func and unref_func_name: - logger.warning(f"Unref function '{unref_func_name}' not found in methods of {bi.get_namespace()}.{bi.get_name()}") + logger.warning( + f"Unref function '{unref_func_name}' not found in methods of {bi.get_namespace()}.{bi.get_name()}" + ) # The type function self._generate_get_type_function(bi) @@ -672,19 +661,17 @@ def _generate_struct(self, bi, generate_class=False, class_of=None): tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(field_type)) if tag == "interface": interface = GIRepository.type_info_get_interface(field_type) - if interface.get_type() == GIRepository.InfoType.STRUCT \ - and interface.get_name() == "MiniObject" \ - and interface.get_namespace() == "Gst": + if ( + interface.get_type() == GIRepository.InfoType.STRUCT + and interface.get_name() == "MiniObject" + and interface.get_namespace() == "Gst" + ): parent = interface # We reuse the gst_mini_object_unref has_destructor = True logger.info(f"Struct {bi.get_name()} inheriting from GstMiniObject") - schema = { - "x-gi-type": "struct", - "x-gi-namespace": f"{bi.get_namespace()}", - "x-gi-name": f"{bi.get_name()}" - } + schema = {"x-gi-type": "struct", "x-gi-namespace": f"{bi.get_namespace()}", "x-gi-name": f"{bi.get_name()}"} if parent: full_parent_name = f"{parent.get_namespace()}{parent.get_name()}" @@ -692,7 +679,7 @@ def _generate_struct(self, bi, generate_class=False, class_of=None): {"$ref": f"#/components/schemas/{full_parent_name}"}, { "type": "object", - } + }, ] else: schema["type"] = "object" @@ -704,21 +691,18 @@ def _generate_struct(self, bi, generate_class=False, class_of=None): if generate_class and class_of: schema["x-gi-class-of"] = f"{class_of.get_namespace()}{class_of.get_name()}" - self.spec.components.schema( - full_name, - schema - ) - + self.spec.components.schema(full_name, schema) + # Mark as generated self.schemas[full_name] = True - + # Generate endpoints for struct methods n_methods = GIRepository.struct_info_get_n_methods(bi) - + # Get official copy and free function names from GIRepository API copy_func_name = None free_func_name = None - + try: copy_func = GIRepository.struct_info_get_copy_function(bi) if copy_func: @@ -726,7 +710,7 @@ def _generate_struct(self, bi, generate_class=False, class_of=None): except AttributeError: # API not available for older GIRepository versions (< 1.76) pass - + try: free_func = GIRepository.struct_info_get_free_function(bi) if free_func: @@ -734,50 +718,56 @@ def _generate_struct(self, bi, generate_class=False, class_of=None): except AttributeError: # API not available for older GIRepository versions (< 1.76) pass - + # Check existing methods for constructors/free and generate endpoints for struct methods for i in range(0, n_methods): bim = GIRepository.struct_info_get_method(bi, i) flags = GIRepository.function_info_get_flags(bim) is_constructor = bool(flags & GIRepository.FunctionInfoFlags.IS_CONSTRUCTOR) method_name = bim.get_name() - + # Check for constructor - if is_constructor or method_name == 'new': + if is_constructor or method_name == "new": has_constructor = True - + # Check for destructor using API first, then fallback if free_func_name and method_name == free_func_name: has_destructor = True - elif not free_func_name and method_name in ['free', 'unref']: + elif not free_func_name and method_name in ["free", "unref"]: has_destructor = True - + # Determine if this is a copy or destructor method is_copy = False is_destructor = False - + # Check copy function if copy_func_name and method_name == copy_func_name: is_copy = True - elif not copy_func_name and method_name in ['copy', 'ref']: - logger.warning(f"Using fallback detection for copy function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}") + elif not copy_func_name and method_name in ["copy", "ref"]: + logger.warning( + f"Using fallback detection for copy function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}" + ) is_copy = True - - # Check destructor function + + # Check destructor function if free_func_name and method_name == free_func_name: is_destructor = True - elif not free_func_name and method_name == 'free': - logger.warning(f"Using fallback detection for free function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}") + elif not free_func_name and method_name == "free": + logger.warning( + f"Using fallback detection for free function '{method_name}' in {bi.get_namespace()}.{bi.get_name()}" + ) is_destructor = True - - self._generate_function(bim, bi, is_constructor=is_constructor, is_copy=is_copy, is_destructor=is_destructor) - + + self._generate_function( + bim, bi, is_constructor=is_constructor, is_copy=is_copy, is_destructor=is_destructor + ) + # Generate endpoints for struct fields n_fields = GIRepository.struct_info_get_n_fields(bi) for i in range(n_fields): field_info = GIRepository.struct_info_get_field(bi, i) self._generate_field_endpoints(field_info, bi) - + # Generate get_type function if the struct has a registered GType self._generate_get_type_function(bi) @@ -799,7 +789,7 @@ def _generate_struct(self, bi, generate_class=False, class_of=None): # Generate generic new/free endpoints if struct doesn't have constructor/free if not has_constructor: self._generate_generic_struct_new(bi) - + if not has_destructor: self._generate_generic_struct_free(bi) @@ -812,68 +802,62 @@ def _generate_callback(self, bi, schema_name=None, emitter_info=None): full_name = schema_name if full_name in self.schemas: return full_name - + # Mark as generated early to prevent circular dependencies self.schemas[full_name] = True - + # Get callback parameters - separate INPUT parameters from OUTPUT parameters n_args = GIRepository.callable_info_get_n_args(bi) request_properties = {} # Input parameters (request body) response_properties = {} # Output parameters + return value (response body) - + # For signal callbacks, prepend the emitter (self) parameter if emitter_info is not None: emitter_namespace = emitter_info.get_namespace() emitter_name = emitter_info.get_name() emitter_full_name = f"{emitter_namespace}{emitter_name}" - + # Add emitter as first parameter with 'none' transfer - request_properties["self"] = { - "$ref": f"#/components/schemas/{emitter_full_name}", - "x-gi-transfer": "none" - } - + request_properties["self"] = {"$ref": f"#/components/schemas/{emitter_full_name}", "x-gi-transfer": "none"} + for i in range(n_args): arg = GIRepository.callable_info_get_arg(bi, i) arg_type = GIRepository.arg_info_get_type(arg) arg_name = arg.get_name() arg_direction = GIRepository.arg_info_get_direction(arg) - + # Get transfer ownership information transfer_str = self._transfer_to_str(GIRepository.arg_info_get_ownership_transfer(arg)) - + param_schema = self._type_to_schema(arg_type) # Ensure param_schema always has a value if not param_schema: param_schema = {"$ref": "#/components/schemas/Pointer"} - + # Add transfer information - param_with_transfer = { - **param_schema, - "x-gi-transfer": transfer_str - } - + param_with_transfer = {**param_schema, "x-gi-transfer": transfer_str} + # Handle output parameters - they go in the response if arg_direction == GIRepository.Direction.OUT: response_properties[arg_name] = param_with_transfer continue - + # Handle input and inout parameters - they go in the request request_properties[arg_name] = param_with_transfer - + # INOUT parameters go in both request and response if arg_direction == GIRepository.Direction.INOUT: response_properties[arg_name] = param_with_transfer - + # Get return type and add to response properties return_type = GIRepository.callable_info_get_return_type(bi) return_schema = self._type_to_schema(return_type) - + # Create a separate return value schema for HTTP responses (not in SSE-only mode) # This includes the return value AND any output parameters if (return_schema or response_properties) and not self.sse_only: return_schema_name = f"{full_name}Return" - + # Add return value to response properties if it exists if return_schema: # Get return value transfer ownership information @@ -881,68 +865,51 @@ def _generate_callback(self, bi, schema_name=None, emitter_info=None): response_properties["return"] = { **return_schema, "x-gi-transfer": return_transfer_str, - "x-gi-null": GIRepository.callable_info_may_return_null(bi) + "x-gi-null": GIRepository.callable_info_may_return_null(bi), } - + # Create the return wrapper schema with all output parameters and return value - return_wrapper_schema = { - "type": "object", - "properties": response_properties - } + return_wrapper_schema = {"type": "object", "properties": response_properties} self.spec.components.schema(return_schema_name, return_wrapper_schema) - + # Add HTTP callback invocation properties only in standard mode (not SSE-only) # These are used when the callback is invoked via HTTP POST (request body) if not self.sse_only: - request_properties["sessionId"] = { - "type": "string", - "description": "Session identifier for routing" - } + request_properties["sessionId"] = {"type": "string", "description": "Session identifier for routing"} request_properties["callbackName"] = { "type": "string", "description": "Name of the callback being invoked", } - request_properties["args"] = { - "type": "array", - "description": "Callback arguments in order", - "items": {} - } - request_properties["invocationNumber"] = { - "type": "integer", - "description": "Sequential invocation counter" - } + request_properties["args"] = {"type": "array", "description": "Callback arguments in order", "items": {}} + request_properties["invocationNumber"] = {"type": "integer", "description": "Sequential invocation counter"} request_properties["timestamp"] = { "type": "string", "format": "date-time", - "description": "Timestamp of callback invocation" + "description": "Timestamp of callback invocation", } - + # Create callback schema (request body) - callback_schema = { - "type": "object", - "x-gi-type": "callback", - "properties": request_properties - } - + callback_schema = {"type": "object", "x-gi-type": "callback", "properties": request_properties} + # Add required fields only in standard mode if not self.sse_only: callback_schema["required"] = ["sessionId", "callbackName", "args"] - + self.spec.components.schema(full_name, callback_schema) return full_name - + def _generate_callback_argument(self, callback_info, arg_info, callback_arg_name): """ Generate OpenAPI callback argument specification for a GObject callback. - + This creates the callback URL parameter and the callbacks object that describes what the server will POST to the client's callback URL. - + Args: callback_info: GIRepository callback info (the callback type) arg_info: GIRepository arg info (the parameter that has this callback type) callback_arg_name: Name of the callback argument (e.g., 'func', 'callback') - + Returns: tuple: (callback_param, callbacks_object, is_sync) """ @@ -952,119 +919,80 @@ def _generate_callback_argument(self, callback_info, arg_info, callback_arg_name # Get the scope from the argument, not the callback type # According to GI, scope can be: call (sync), async, notified, forever scope = GIRepository.arg_info_get_scope(arg_info) - + # Determine if synchronous based on scope # GI_SCOPE_TYPE_CALL (0) means the callback is called synchronously # Other scopes are asynchronous: # - GI_SCOPE_TYPE_ASYNC: fire and forget # - GI_SCOPE_TYPE_NOTIFIED: called multiple times until destroyed # - GI_SCOPE_TYPE_FOREVER: never destroyed - is_sync = (scope == GIRepository.ScopeType.CALL) - + is_sync = scope == GIRepository.ScopeType.CALL # Create callback URL parameter callback_param = { - 'name': f'{callback_arg_name}', - 'in': 'query', - 'required': True, - 'schema': { - 'type': 'string', - 'format': 'uri', - 'description': f'URL to invoke for {callback_name} callback' - }, - 'description': f'Callback URL that will be invoked with {callback_name} events', - 'x-gi-callback': f'#/components/schemas/{full_name}', - 'x-gi-callback-style': 'sync' if is_sync else 'async' + "name": f"{callback_arg_name}", + "in": "query", + "required": True, + "schema": {"type": "string", "format": "uri", "description": f"URL to invoke for {callback_name} callback"}, + "description": f"Callback URL that will be invoked with {callback_name} events", + "x-gi-callback": f"#/components/schemas/{full_name}", + "x-gi-callback-style": "sync" if is_sync else "async", } # Reference the callback schema which now includes invocation properties - callback_schema_ref = f'#/components/schemas/{full_name}' - + callback_schema_ref = f"#/components/schemas/{full_name}" + # Build response schema based on sync/async if is_sync: # Synchronous callback: check if it has a return value return_type = GIRepository.callable_info_get_return_type(callback_info) return_schema = self._type_to_schema(return_type) - + if return_schema: # Reference the return value schema created in _generate_callback return_schema_name = f"{full_name}Return" - return_schema_ref = f'#/components/schemas/{return_schema_name}' - + return_schema_ref = f"#/components/schemas/{return_schema_name}" + # Has return value: respond with 200 and the return value directly responses = { - '200': { - 'description': 'Callback processed successfully, returns callback result', - 'content': { - 'application/json': { - 'schema': {'$ref': return_schema_ref} - } - } - }, - '400': { - 'description': 'Invalid callback request' - }, - '401': { - 'description': 'Invalid signature or authentication failed' + "200": { + "description": "Callback processed successfully, returns callback result", + "content": {"application/json": {"schema": {"$ref": return_schema_ref}}}, }, - '500': { - 'description': 'Callback processing error' - } + "400": {"description": "Invalid callback request"}, + "401": {"description": "Invalid signature or authentication failed"}, + "500": {"description": "Callback processing error"}, } else: # No return value: respond with 204 No Content responses = { - '204': { - 'description': 'Callback processed successfully (no content)' - }, - '400': { - 'description': 'Invalid callback request' - }, - '401': { - 'description': 'Invalid signature or authentication failed' - }, - '500': { - 'description': 'Callback processing error' - } + "204": {"description": "Callback processed successfully (no content)"}, + "400": {"description": "Invalid callback request"}, + "401": {"description": "Invalid signature or authentication failed"}, + "500": {"description": "Callback processing error"}, } else: # Asynchronous callback: fire-and-forget with 204 No Content responses = { - '204': { - 'description': 'Callback received (no content)' - }, - '400': { - 'description': 'Invalid callback request' - }, - '401': { - 'description': 'Invalid signature or authentication failed' - }, - '500': { - 'description': 'Callback processing error' - } + "204": {"description": "Callback received (no content)"}, + "400": {"description": "Invalid callback request"}, + "401": {"description": "Invalid signature or authentication failed"}, + "500": {"description": "Callback processing error"}, } # Build OpenAPI callback/webhook schema callback_schema_obj = { callback_name: { - '{$request.query.' + callback_param['name'] + '}': { - 'post': { - 'summary': f'Callback for {callback_name}', - 'description': f'Invoked by the server when {callback_name} callback fires', - 'requestBody': { - 'required': True, - 'content': { - 'application/json': { - 'schema': {'$ref': callback_schema_ref} - } - } + "{$request.query." + callback_param["name"] + "}": { + "post": { + "summary": f"Callback for {callback_name}", + "description": f"Invoked by the server when {callback_name} callback fires", + "requestBody": { + "required": True, + "content": {"application/json": {"schema": {"$ref": callback_schema_ref}}}, }, - 'responses': responses, - 'security': [ - { - 'callbackSignature': [] - } - ] + "responses": responses, + "security": [{"callbackSignature": []}], } } } @@ -1075,10 +1003,10 @@ def _generate_generic_struct_new(self, bi): """Generate a generic 'new' endpoint for structs without constructors""" namespace = bi.get_namespace() name = bi.get_name() - + # Create API path: /{namespace}/{name}/new api = f"/{namespace}/{name}/new" - + # Build operation definition for the generic constructor operation = { "summary": f"Allocate memory for {name} struct", @@ -1093,27 +1021,25 @@ def _generate_generic_struct_new(self, bi): "application/json": { "schema": { "type": "object", - "properties": { - "return": {"$ref": f"#/components/schemas/{namespace}{name}"} - } + "properties": {"return": {"$ref": f"#/components/schemas/{namespace}{name}"}}, } } - } + }, } }, - "x-gi-constructor": True + "x-gi-constructor": True, } - + self.spec.path(path=api, operations={"get": operation}) - + def _generate_generic_struct_free(self, bi): """Generate a generic 'free' endpoint for structs without free methods""" namespace = bi.get_namespace() name = bi.get_name() - + # Create API path: /{namespace}/{name}/{self}/free api = f"/{namespace}/{name}/{{self}}/free" - + # Build operation definition for the generic destructor operation = { "summary": f"Free memory for {name} struct", @@ -1126,15 +1052,13 @@ def _generate_generic_struct_free(self, bi): "in": "path", "required": True, "schema": {"$ref": f"#/components/schemas/{namespace}{name}"}, - "description": "Pointer to the struct to free" + "description": "Pointer to the struct to free", } ], - "responses": { - "204": {"description": "No Content"} - }, - "x-gi-destructor": True + "responses": {"204": {"description": "No Content"}}, + "x-gi-destructor": True, } - + self.spec.path(path=api, operations={"get": operation}) def _generate_get_type_function(self, bi): @@ -1143,10 +1067,10 @@ def _generate_get_type_function(self, bi): type_init_func = GIRepository.registered_type_info_get_type_init(bi) if not type_init_func: return - + namespace = bi.get_namespace() # Build the API path (e.g., "/Gst/bin_get_type") - api = f"/{namespace}/{bi.get_name()}/get_type" + api = f"/{namespace}/{bi.get_name()}/get_type" # Build operation definition operation = { "summary": f"Get GType for {bi.get_name()}", @@ -1158,19 +1082,12 @@ def _generate_get_type_function(self, bi): "200": { "description": "Success", "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "return": {"type": "string" } - } - } - } - } + "application/json": {"schema": {"type": "object", "properties": {"return": {"type": "string"}}}} + }, } }, } - + self.spec.path(path=api, operations={"get": operation}) def _generate_signals(self, bi): @@ -1178,31 +1095,31 @@ def _generate_signals(self, bi): namespace = bi.get_namespace() obj_name = bi.get_name() full_name = f"{namespace}{obj_name}" - + # Get number of signals for this object n_signals = GIRepository.object_info_get_n_signals(bi) - + if n_signals == 0: return - + # Iterate through all signals for i in range(n_signals): signal_info = GIRepository.object_info_get_signal(bi, i) signal_name = signal_info.get_name() - + # Generate callback schema for this signal with a proper name # Format: {Namespace}{ObjectName}{SignalName}Handler (e.g., GstBusSyncMessageHandler) - signal_name_camel = ''.join(word.capitalize() for word in signal_name.split('-')) + signal_name_camel = "".join(word.capitalize() for word in signal_name.split("-")) callback_schema_name = f"{namespace}{obj_name}{signal_name_camel}Handler" callback_schema_name = self._generate_callback(signal_info, callback_schema_name, bi) - + # Generate the POST endpoint for connecting to this signal # Path: /{namespace}/{ObjectName}/{self}/signals/{signal-name}/connect api = f"/{namespace}/{obj_name}/{{self}}/signals/{signal_name}/connect" - + # Use underscores in operationId to keep it parseable (3-4 parts) - signal_name_underscore = signal_name.replace('-', '_') - + signal_name_underscore = signal_name.replace("-", "_") + operation = { "summary": f"Connect to '{signal_name}' signal on {obj_name}", "description": f"Connects a callback handler to the '{signal_name}' signal", @@ -1213,12 +1130,10 @@ def _generate_signals(self, bi): "name": "self", "in": "path", "required": True, - "schema": { - "$ref": f"#/components/schemas/{full_name}" - }, + "schema": {"$ref": f"#/components/schemas/{full_name}"}, "description": f"The {obj_name} instance", "x-gi-transfer": "none", - "style": "simple" + "style": "simple", } ], "requestBody": { @@ -1231,12 +1146,12 @@ def _generate_signals(self, bi): "properties": { "flags": { "$ref": "#/components/schemas/GObjectConnectFlags", - "description": "Connection flags (G_CONNECT_AFTER, G_CONNECT_SWAPPED, or 0)" + "description": "Connection flags (G_CONNECT_AFTER, G_CONNECT_SWAPPED, or 0)", } - } + }, } } - } + }, }, "responses": { "200": { @@ -1245,19 +1160,14 @@ def _generate_signals(self, bi): "application/json": { "schema": { "type": "object", - "properties": { - "return": { - "type": "number", - "description": "Signal handler ID" - } - } + "properties": {"return": {"type": "number", "description": "Signal handler ID"}}, } } - } + }, } - } + }, } - + # Add callback handling for non-SSE mode if not self.sse_only: self._add_non_sse_parameters(operation) @@ -1267,10 +1177,10 @@ def _generate_signals(self, bi): "format": "uri", "description": f"Callback URL for '{signal_name}' signal handler", "x-gi-callback": f"#/components/schemas/{callback_schema_name}", - "x-gi-callback-style": "async" + "x-gi-callback-style": "async", } operation["requestBody"]["content"]["application/json"]["schema"]["required"].append("handler") - + # Add callback definition operation["callbacks"] = { callback_schema_name: { @@ -1282,36 +1192,22 @@ def _generate_signals(self, bi): "required": True, "content": { "application/json": { - "schema": { - "$ref": f"#/components/schemas/{callback_schema_name}" - } + "schema": {"$ref": f"#/components/schemas/{callback_schema_name}"} } - } + }, }, "responses": { - "204": { - "description": "Callback received" - }, - "400": { - "description": "Invalid callback request" - }, - "401": { - "description": "Invalid signature" - }, - "500": { - "description": "Callback processing error" - } + "204": {"description": "Callback received"}, + "400": {"description": "Invalid callback request"}, + "401": {"description": "Invalid signature"}, + "500": {"description": "Callback processing error"}, }, - "security": [ - { - "callbackSignature": [] - } - ] + "security": [{"callbackSignature": []}], } } } } - + # Register the endpoint self.spec.path(path=api, operations={"post": operation}) @@ -1319,10 +1215,10 @@ def _generate_generic_object_ref(self, bi): """Generate a generic 'ref' endpoint for objects that don't export it through GI""" namespace = bi.get_namespace() name = bi.get_name() - + # Create API path: /{namespace}/{name}/{self}/ref api = f"/{namespace}/{name}/{{self}}/ref" - + # Build operation definition for the ref function operation = { "summary": f"Increment reference count for {name}", @@ -1335,7 +1231,7 @@ def _generate_generic_object_ref(self, bi): "in": "path", "required": True, "schema": {"$ref": f"#/components/schemas/{namespace}{name}"}, - "description": "Pointer to the object" + "description": "Pointer to the object", } ], "responses": { @@ -1349,27 +1245,27 @@ def _generate_generic_object_ref(self, bi): "return": { "$ref": f"#/components/schemas/{namespace}{name}", "x-gi-transfer": "none", - "x-gi-null": False + "x-gi-null": False, } - } + }, } } - } + }, } }, - "x-gi-copy": True + "x-gi-copy": True, } - + self.spec.path(path=api, operations={"get": operation}) def _generate_generic_object_unref(self, bi): """Generate a generic 'unref' endpoint for objects that don't export it through GI""" namespace = bi.get_namespace() name = bi.get_name() - + # Create API path: /{namespace}/{name}/{self}/unref api = f"/{namespace}/{name}/{{self}}/unref" - + # Build operation definition for the unref function operation = { "summary": f"Decrement reference count for {name}", @@ -1382,15 +1278,13 @@ def _generate_generic_object_unref(self, bi): "in": "path", "required": True, "schema": {"$ref": f"#/components/schemas/{namespace}{name}"}, - "description": "Pointer to the object" + "description": "Pointer to the object", } ], - "responses": { - "204": {"description": "No Content"} - }, - "x-gi-destructor": True + "responses": {"204": {"description": "No Content"}}, + "x-gi-destructor": True, } - + self.spec.path(path=api, operations={"get": operation}) def _generate_field_endpoints(self, field_info, struct_info): @@ -1413,18 +1307,17 @@ def _generate_field_endpoints(self, field_info, struct_info): logger.warning(f"Skipping field {field_name} of struct {struct_name} as it has a setter already") return - # Get field type and offset field_type = GIRepository.field_info_get_type(field_info) field_offset = GIRepository.field_info_get_offset(field_info) - + # Convert field type to schema field_schema = self._type_to_schema(field_type) if not field_schema: # Skip fields with unsupported types logger.warning(f"Skipping field {field_name} of struct {struct_name} due to unsupported type") return - + # Generate GET endpoint (always available for readable fields) # Use 'fields' prefix to avoid collision with method endpoints get_api = f"/{namespace}/{struct_name}/{{self}}/fields/{field_name}" @@ -1439,7 +1332,7 @@ def _generate_field_endpoints(self, field_info, struct_info): "in": "path", "required": True, "schema": {"$ref": f"#/components/schemas/{namespace}{struct_name}"}, - "description": "Pointer to the struct instance" + "description": "Pointer to the struct instance", } ], "responses": { @@ -1454,17 +1347,17 @@ def _generate_field_endpoints(self, field_info, struct_info): **field_schema, "x-gi-null": True if "$ref" in field_schema else False, } - } + }, } } - } + }, } }, - "x-gi-field": True + "x-gi-field": True, } - + self.spec.path(path=get_api, operations={"get": get_operation}) - + # Generate PUT endpoint only if the field is writable if is_writable: # Use 'fields' prefix to avoid collision with method endpoints @@ -1480,22 +1373,20 @@ def _generate_field_endpoints(self, field_info, struct_info): "in": "path", "required": True, "schema": {"$ref": f"#/components/schemas/{namespace}{struct_name}"}, - "description": "Pointer to the struct instance" + "description": "Pointer to the struct instance", }, { "name": "value", "in": "query", "required": True, "schema": field_schema, - "description": f"Value to write to {field_name}" - } + "description": f"Value to write to {field_name}", + }, ], - "responses": { - "204": {"description": "No Content"} - }, - "x-gi-field": True + "responses": {"204": {"description": "No Content"}}, + "x-gi-field": True, } - + self.spec.path(path=put_api, operations={"put": put_operation}) def _generate_enum(self, bi): @@ -1503,23 +1394,23 @@ def _generate_enum(self, bi): full_name = f"{bi.get_namespace()}{bi.get_name()}" if full_name in self.schemas: return - + # Mark as generated early to prevent circular dependencies self.schemas[full_name] = True - + # Get all enum values n_values = GIRepository.enum_info_get_n_values(bi) enum_values = [] for i in range(n_values): value_info = GIRepository.enum_info_get_value(bi, i) enum_values.append(value_info.get_name()) - + # Create enum schema with string values # OpenAPI will accept string values, but we'll need to convert them # to integers when calling Frida info_type = bi.get_type() gi_type = "enum" if info_type == GIRepository.InfoType.ENUM else "flags" - + self.spec.components.schema( full_name, { @@ -1529,26 +1420,26 @@ def _generate_enum(self, bi): "x-gi-type": gi_type, "x-gi-namespace": f"{bi.get_namespace()}", "x-gi-name": f"{bi.get_name()}", - } + }, ) - + # Generate endpoints for enum methods for i in range(0, GIRepository.enum_info_get_n_methods(bi)): bim = GIRepository.enum_info_get_method(bi, i) self._generate_function(bim, bi) - + # Generate get_type function for the enum self._generate_get_type_function(bi) def _generate_missing(self, ns): # For GLib, we don't have g_signal_connect_data if ns == "GObject": - post_api = f"/GObject/signal_connect_data" + post_api = "/GObject/signal_connect_data" post_operation = { - "summary": f"Connects a callback function to a signal for a particular object", - "description": f"", - "operationId": f"GObject--signal_connect_data", - "tags": [f"GObject"], + "summary": "Connects a callback function to a signal for a particular object", + "description": "", + "operationId": "GObject--signal_connect_data", + "tags": ["GObject"], "parameters": [], # Empty list that will be populated by _add_non_sse_parameters if needed "requestBody": { "required": True, @@ -1561,20 +1452,20 @@ def _generate_missing(self, ns): "instance": { "$ref": "#/components/schemas/GObjectObject", "description": "The object instance to connect the signal to", - "x-gi-transfer": "none" + "x-gi-transfer": "none", }, "detailed_signal": { "type": "string", - "description": "The signal name, optionally with detail" + "description": "The signal name, optionally with detail", }, "connect_flags": { "$ref": "#/components/schemas/GObjectConnectFlags", - "description": "Connection flags" - } - } + "description": "Connection flags", + }, + }, } } - } + }, }, "responses": { "200": { @@ -1583,15 +1474,10 @@ def _generate_missing(self, ns): "application/json": { "schema": { "type": "object", - "properties": { - "return": { - "type": "number", - "description": "Handler ID" - } - } + "properties": {"return": {"type": "number", "description": "Handler ID"}}, } } - } + }, } }, } @@ -1604,50 +1490,34 @@ def _generate_missing(self, ns): "format": "uri", "description": "Callback URL that will be invoked with signal events", "x-gi-callback": "#/components/schemas/GObjectCallback", - "x-gi-callback-style": "async" + "x-gi-callback-style": "async", } post_operation["requestBody"]["content"]["application/json"]["schema"]["required"].append("c_handler") - + # Add the callbacks # FIXME reuse the callback generator somehow post_operation["callbacks"] = { - "Callback": { - "{$request.body#/c_handler}": { - "post": { - "summary": "Callback for signal handler", - "description": "Invoked by the server when the signal is emitted", - "requestBody": { - "required": True, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GObjectCallback" - } + "Callback": { + "{$request.body#/c_handler}": { + "post": { + "summary": "Callback for signal handler", + "description": "Invoked by the server when the signal is emitted", + "requestBody": { + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/GObjectCallback"}} + }, + }, + "responses": { + "204": {"description": "Callback received (no content)"}, + "400": {"description": "Invalid callback request"}, + "401": {"description": "Invalid signature or authentication failed"}, + "500": {"description": "Callback processing error"}, + }, + "security": [{"callbackSignature": []}], } - } - }, - "responses": { - "204": { - "description": "Callback received (no content)" - }, - "400": { - "description": "Invalid callback request" - }, - "401": { - "description": "Invalid signature or authentication failed" - }, - "500": { - "description": "Callback processing error" - } - }, - "security": [ - { - "callbackSignature": [] - } - ] - } + } } - } } else: # FIXME return the callback id diff --git a/girest/girest/resolvers.py b/girest/girest/resolvers.py index 899b5b5..2127555 100644 --- a/girest/girest/resolvers.py +++ b/girest/girest/resolvers.py @@ -2,33 +2,30 @@ # We need to get rid of passing the namespace/version, it should be fetched # based on the actual operation information -import logging -import json import asyncio -import threading +import json +import logging import queue +import threading from collections import deque -from typing import Dict, Any, Optional +from typing import Any, Dict import connexion -from connexion.resolver import Resolver, Resolution import gi +from connexion.resolver import Resolution, Resolver + gi.require_version("GIRepository", "2.0") -from gi.repository import GIRepository -from starlette.responses import StreamingResponse -# Import for type hints -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from main import GIRest +from gi.repository import GIRepository # noqa: E402 +from starlette.responses import StreamingResponse # noqa: E402 try: from .utils import parse_operation_id except ImportError: # Fallback for when module is imported directly (e.g., in tests) - from utils import parse_operation_id + from utils import parse_operation_id # noqa: E402 -from .callbacks import CallbackHandler +from .callbacks import CallbackHandler # noqa: E402 logger = logging.getLogger("girest") @@ -37,60 +34,51 @@ # Helper Classes for Frida Communication # ============================================================================ + class FridaCommandSerializer: """Serializes Python API calls into Frida command format.""" - + @staticmethod def serialize_call(symbol: str, method_info: dict, args: list) -> dict: """ Serialize a function/method call. - + Args: symbol: Native function symbol name method_info: GI method metadata (JSON representation) args: List of arguments - + Returns: Command dictionary for Frida """ - return { - "type": "call", - "symbol": symbol, - "method_info": method_info, - "args": args - } - + return {"type": "call", "symbol": symbol, "method_info": method_info, "args": args} + @staticmethod def serialize_get_field(struct_ptr: str, offset: int, field_type: dict) -> dict: """ Serialize a get_field operation. - + Args: struct_ptr: Pointer to struct offset: Field offset in bytes field_type: Field type metadata - + Returns: Command dictionary for Frida """ - return { - "type": "get_field", - "ptr": struct_ptr, - "offset": offset, - "field_type": field_type - } - + return {"type": "get_field", "ptr": struct_ptr, "offset": offset, "field_type": field_type} + @staticmethod def serialize_set_field(struct_ptr: str, offset: int, field_type: dict, value: Any) -> dict: """ Serialize a set_field operation. - + Args: struct_ptr: Pointer to struct offset: Field offset in bytes field_type: Field type metadata value: Value to set - + Returns: Command dictionary for Frida """ @@ -99,177 +87,171 @@ def serialize_set_field(struct_ptr: str, offset: int, field_type: dict, value: A "ptr": struct_ptr, # Changed from "struct_ptr" to match run() dispatcher "offset": offset, "field_type": field_type, - "value": value + "value": value, } - + @staticmethod def serialize_alloc(size: int) -> dict: """ Serialize a memory allocation. - + Args: size: Number of bytes to allocate - + Returns: Command dictionary for Frida """ - return { - "type": "alloc", - "size": size - } - + return {"type": "alloc", "size": size} + @staticmethod def serialize_free(ptr: str) -> dict: """ Serialize a memory free. - + Args: ptr: Pointer to free - + Returns: Command dictionary for Frida """ - return { - "type": "free", - "ptr": ptr - } + return {"type": "free", "ptr": ptr} class FridaMessageBus: """ Manages bidirectional communication with Frida script. - + Handles two communication patterns: 1. RPC calls: Direct synchronous calls via exports_sync.run() 2. Queued calls: Asynchronous message-based calls with correlation IDs (used for reentrant calls from callback context) """ - + def __init__(self, script): """ Initialize the message bus. - + Args: script: Frida script instance """ self.script = script self._queued_responses: Dict[str, queue.Queue] = {} self._response_lock = threading.Lock() - + def execute(self, command: dict, headers: dict, is_async: bool = False, timeout: float = 30.0) -> Any: """ Execute command via appropriate channel based on headers. - + Checks for X-Correlation-Id header to determine execution path: - If present: Uses queued execution (for callback context/thread affinity) - If absent: Uses direct RPC execution - + Args: command: Serialized command dictionary headers: HTTP request headers (should be connexion.request.headers or dict-like) is_async: If True with correlation_id, fire-and-forget (don't wait for response) timeout: Timeout in seconds - + Returns: Result from Frida (None if is_async=True) - + Raises: TimeoutError: If response not received within timeout Exception: If Frida reports an error """ - correlation_id = headers.get('X-Correlation-Id') - + correlation_id = headers.get("X-Correlation-Id") + if correlation_id: return self._execute_queued(command, correlation_id, is_async, timeout) else: return self._execute_direct(command, timeout) - + def _execute_direct(self, command: dict, timeout: float = 30.0) -> Any: """ Execute command directly via RPC (blocking). Used for normal API calls without correlation ID. - + Args: command: Serialized command dictionary timeout: Timeout in seconds (currently not enforced for RPC) - + Returns: Result from Frida """ command_json = json.dumps(command) return self.script.exports_sync.run(command_json) - + def _execute_queued(self, command: dict, correlation_id: str, is_async: bool = False, timeout: float = 30.0) -> Any: """ Execute command via message queue (for callback context). Sends command to Frida and waits for response via on_message. - + This is used for reentrant API calls from within callbacks, ensuring the command executes on the correct Frida thread (the callback thread). - + Args: command: Serialized command dictionary correlation_id: Correlation ID for routing to callback thread is_async: If True, fire-and-forget (don't wait for response) timeout: Timeout in seconds - + Returns: Result from Frida (None if is_async=True) - + Raises: TimeoutError: If response not received within timeout Exception: If Frida reports an error """ # Serialize command to JSON string command_json = json.dumps(command) - + # Send queued-call message to Frida using callback-specific type # The Frida callback's recv() loop will receive this on the correct thread - self.script.post({ - "type": f"callback-{correlation_id}", - "kind": "queued-call", - "correlation_id": correlation_id, - "command": command_json, - "is_async": is_async - }) - + self.script.post( + { + "type": f"callback-{correlation_id}", + "kind": "queued-call", + "correlation_id": correlation_id, + "command": command_json, + "is_async": is_async, + } + ) + # If async, don't wait for response if is_async: logger.debug(f"Async queued call sent for correlation_id={correlation_id} (fire-and-forget)") return None - + # For synchronous calls, create response queue and wait response_queue = queue.Queue(maxsize=1) - + with self._response_lock: self._queued_responses[correlation_id] = response_queue - + try: # Wait for response (blocks until message arrives) try: response = response_queue.get(timeout=timeout) except queue.Empty: - raise TimeoutError( - f"Queued call timed out after {timeout}s for correlation_id={correlation_id}" - ) - + raise TimeoutError(f"Queued call timed out after {timeout}s for correlation_id={correlation_id}") + # Check for errors from Frida if not response.get("success", True): error = response.get("error", "Unknown error") raise Exception(f"Frida execution failed: {error}") - + return response.get("result") - + finally: # Cleanup: remove queue to prevent memory leaks with self._response_lock: self._queued_responses.pop(correlation_id, None) - + def handle_response_message(self, correlation_id: str, response: dict): """ Called from on_message when a queued-call-response arrives. Wakes up the thread waiting in execute_queued(). - + Args: correlation_id: Correlation ID of the response response: Response payload from Frida @@ -279,9 +261,7 @@ def handle_response_message(self, correlation_id: str, response: dict): response_queue = self._queued_responses[correlation_id] response_queue.put(response) else: - logger.warning( - f"Received queued-call-response for unknown correlation_id: {correlation_id}" - ) + logger.warning(f"Received queued-call-response for unknown correlation_id: {correlation_id}") class GIResolver(Resolver): @@ -300,10 +280,10 @@ def __init__(self, sse_buffer_size: int = 100): def push_sse_event(self, event_data: dict): """ Push an event to the SSE buffer. This is thread-safe and non-blocking. - + If the buffer is full, the oldest event will be discarded to make room. Can be safely called from any thread (e.g., Frida's message handler). - + Args: event_data: Dictionary containing event data to be sent to SSE clients """ @@ -312,28 +292,25 @@ def push_sse_event(self, event_data: dict): with self._buffer_lock: event_id = self._event_counter self._event_counter += 1 - + # Wrap the event data with an ID - event_wrapper = { - "_sse_id": event_id, - "data": event_data - } - + event_wrapper = {"_sse_id": event_id, "data": event_data} + self.sse_events.append(event_wrapper) - + # Set the event to notify waiting clients (outside lock) # Use call_soon_threadsafe if called from a different thread if self.sse_event is not None and self._event_loop is not None: self._event_loop.call_soon_threadsafe(self.sse_event.set) - + async def sse_event_generator(self): """ Async generator that yields SSE events from the buffer. - + Yields events from the current position in the buffer and then waits for new events to be pushed. Each generator instance tracks its own position independently using sequential event IDs. - + Note: If the buffer rotates (oldest events are discarded) while a client is connected, the client may miss events. This is acceptable for the use case as it prevents unbounded memory growth. @@ -342,17 +319,17 @@ async def sse_event_generator(self): if self.sse_event is None: self._event_loop = asyncio.get_running_loop() self.sse_event = asyncio.Event() - + # Track the last event ID we've sent last_sent_id = -1 - + while True: has_new_events = False - + # Take an atomic snapshot to avoid mutation during iteration with self._buffer_lock: snapshot = list(self.sse_events) - + # Iterate over the snapshot for event_wrapper in snapshot: event_id = event_wrapper["_sse_id"] @@ -361,48 +338,43 @@ async def sse_event_generator(self): has_new_events = True # Yield only the data, not the wrapper yield event_wrapper["data"] - + # If we yielded events, check again for more before waiting if has_new_events: continue - + # Wait for new events await self.sse_event.wait() self.sse_event.clear() - + async def sse_callbacks_endpoint(self): """ SSE endpoint that streams callback events. - + This endpoint is registered at /GIRest/callbacks and streams events in Server-Sent Events format. - + Returns: Async generator yielding SSE-formatted messages """ async for event_data in self.sse_event_generator(): # Format as SSE - message = f'data: {json.dumps(event_data)}\n\n' + message = f"data: {json.dumps(event_data)}\n\n" yield message def resolve(self, operation): """We overwrite the resolve method to have access to the path schema""" - return Resolution( - self.get_function_from_operation(operation), operation.operation_id - ) + return Resolution(self.get_function_from_operation(operation), operation.operation_id) def get_function_from_operation(self, operation): async def sse_callback(): """SSE endpoint for callback events.""" return StreamingResponse( self.sse_callbacks_endpoint(), - media_type='text/event-stream', - headers={ - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - } + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, ) + operation_id = operation.operation_id if not operation_id: @@ -420,17 +392,17 @@ async def sse_callback(): class FridaResolver(GIResolver): """ Resolver for Connexion that uses Frida to call functions in a remote process. - + This resolver generates JSON representations of GIRepository function/method definitions that are compatible with the girest.js Frida script. - + The resolver: 1. Connects to a target process via Frida 2. Loads the girest.js script 3. For each API operation, finds the corresponding GIRepository function 4. Generates a JSON representation of the function signature 5. Creates a handler that calls the Frida script with the JSON and arguments - + Example JSON format: { "arguments": [ @@ -451,18 +423,19 @@ class FridaResolver(GIResolver): "returns": "int32" } """ + def __init__( - self, - namespace: str, - version: str, - pid: int, - *, - scripts=None, - on_log=None, - on_message=None, - sse_buffer_size=100, - sse_only=False - ): + self, + namespace: str, + version: str, + pid: int, + *, + scripts=None, + on_log=None, + on_message=None, + sse_buffer_size=100, + sse_only=False, + ): # Load the corresponding Gir file self.repo = GIRepository.Repository() self.repo.require(namespace, version, 0) @@ -481,19 +454,19 @@ def __init__( self._callback_registry = {} # SSE mode flag self.sse_only = sse_only - + # Initialize helper classes for Frida communication self.command_serializer = FridaCommandSerializer() self.message_bus = None # Will be initialized after Frida connection - + # Connect to the corresponding process self._connect_frida(scripts, on_log, on_message) - + # Initialize message bus with the Frida script self.message_bus = FridaMessageBus(self.scripts[0]) - + super().__init__(sse_buffer_size) - + def _build_enum_mappings(self): """Build mappings from enum string names to integer values""" for i in range(0, self.repo.get_n_infos(self.ns)): @@ -510,13 +483,13 @@ def _build_enum_mappings(self): def _load_script(self, script_path, on_log, on_message): s = None - with open(script_path, 'r') as f: + with open(script_path, "r") as f: s = self.session.create_script(f.read()) - + # Set up message handler - s.on('message', on_message) + s.on("message", on_message) s.set_log_handler(on_log) - + # Load and initialize the script s.load() s.exports_sync.init() @@ -524,14 +497,15 @@ def _load_script(self, script_path, on_log, on_message): def _connect_frida(self, scripts, on_log, on_message): """Connect to the target process using Frida""" - import frida import os + import frida + # Attach to the process self.session = frida.attach(self.pid) - + # We need to find the girest.js file - script_path = os.path.join(os.path.dirname((os.path.dirname(__file__))), 'girest.js') + script_path = os.path.join(os.path.dirname((os.path.dirname(__file__))), "girest.js") self._load_script(script_path, self._on_log, self._on_message) # Now load every passed in script and register the on_log and on_message for s in scripts: @@ -553,18 +527,18 @@ def _on_message(self, message, data): return payload = message.get("payload", {}) kind = payload.get("kind") - + # Handle queued call responses (for reentrant calls from callbacks) if kind == "queued-call-response": correlation_id = payload.get("correlation_id") self.message_bus.handle_response_message(correlation_id, payload) return - + # Handle callbacks if kind == "callback": callback_data = payload["data"] callback_id = callback_data.get("callback_id") - + if self.sse_only: # SSE mode: push event to buffer for client polling self.push_sse_event(callback_data) @@ -573,47 +547,51 @@ def _on_message(self, message, data): if callback_id is None: logger.error("Callback invocation missing callback_id") return - + # Look up callback metadata metadata = self._callback_registry.get(callback_id) if not metadata: logger.error(f"Callback {callback_id} not found in registry") # Still need to unlock Frida with a null result - self.scripts[0].post({ - "type": f"callback-{callback_id}", - "kind": "callback-response", - "callback_id": callback_id, - "result": None - }) + self.scripts[0].post( + { + "type": f"callback-{callback_id}", + "kind": "callback-response", + "callback_id": callback_id, + "result": None, + } + ) return - + # Get the handler instance from the registry (created during registration) handler = metadata.get("handler") if not handler: logger.error(f"Callback {callback_id} does not have a handler") # Still need to unlock Frida with a null result - self.scripts[0].post({ - "type": f"callback-{callback_id}", - "kind": "callback-response", - "callback_id": callback_id, - "result": None - }) + self.scripts[0].post( + { + "type": f"callback-{callback_id}", + "kind": "callback-response", + "callback_id": callback_id, + "result": None, + } + ) return - + # Get raw args and convert enums to strings raw_args = callback_data["args"] callback_name = metadata["name"] - + # Convert callback arguments (especially enum integers to strings) args = self._convert_callback_args(raw_args, metadata["callback_type"]) - + # Note: GI scope (call/async/notified/forever) only affects WHEN the # callback is invoked, not HOW we handle it. All callbacks: # - Make synchronous HTTP requests # - Wait for response # - May or may not have return values (depends on callback signature) result = None - + # IMPORTANT: Handle callback in a separate thread to allow reentrancy # This allows the main thread to continue processing HTTP requests # while we wait for the callback response @@ -622,15 +600,17 @@ def handle_callback_in_thread(): # All callbacks handled uniformly # Pass callback_id for correlation tracking (thread affinity) result = handler.invoke(callback_name, callback_id, args) - + # Unlock Frida thread with the result - self.scripts[0].post({ - "type": f"callback-{callback_id}", - "kind": "callback-response", - "callback_id": callback_id, - "result": result - }) - + self.scripts[0].post( + { + "type": f"callback-{callback_id}", + "kind": "callback-response", + "callback_id": callback_id, + "result": result, + } + ) + # Run callback handling in a daemon thread # This allows the main thread to keep processing HTTP requests callback_thread = threading.Thread(target=handle_callback_in_thread, daemon=True) @@ -640,21 +620,21 @@ def handle_callback_in_thread(): # For now, just log other messages logger.debug(f"Message from Frida: {message}") - def _generate_callback(self, url, arg_info, cb_info, headers): + def _generate_callback(self, url, arg_info, cb_info, headers): session_id = None callback_secret = None - + try: # Access headers from connexion.request - session_id = headers.get('session-id') - callback_secret = headers.get('callback-secret') + session_id = headers.get("session-id") + callback_secret = headers.get("callback-secret") except Exception as e: logger.warning(f"Could not access connexion.request.headers: {e}") # If this is a callback argument, assign a unique callback_id and get scope callback_id = self._callback_id_counter self._callback_id_counter += 1 - + # Get callback scope from GI metadata scope = GIRepository.arg_info_get_scope(arg_info) # Map GI scope to string for easier handling @@ -663,19 +643,14 @@ def _generate_callback(self, url, arg_info, cb_info, headers): GIRepository.ScopeType.CALL: "call", # Synchronous GIRepository.ScopeType.ASYNC: "async", # Fire and forget GIRepository.ScopeType.NOTIFIED: "notified", # Multiple calls - GIRepository.ScopeType.FOREVER: "forever" # Never destroyed + GIRepository.ScopeType.FOREVER: "forever", # Never destroyed } scope = scope_map.get(scope, "async") arg_name = arg_info.get_name() - + # Create the handler once during registration so invocation_count persists - handler = CallbackHandler( - callback_url=url, - session_id=session_id, - secret=callback_secret, - timeout=10 - ) - + handler = CallbackHandler(callback_url=url, session_id=session_id, secret=callback_secret, timeout=10) + # Register each callback argument self._callback_registry[callback_id] = { "url": url, @@ -684,7 +659,7 @@ def _generate_callback(self, url, arg_info, cb_info, headers): "scope": scope, "name": arg_name, "callback_type": cb_info, - "handler": handler # Store handler instance for reuse + "handler": handler, # Store handler instance for reuse } logger.debug(f"Registered callback {callback_id}: {arg_name} -> {url}") return callback_id @@ -693,15 +668,10 @@ def _register_signal_callback(self, url, signal_name, signal_info, session_id, c """Register a callback for a signal connection""" callback_id = self._callback_id_counter self._callback_id_counter += 1 - + # Create the handler once during registration so invocation_count persists - handler = CallbackHandler( - callback_url=url, - session_id=session_id, - secret=callback_secret, - timeout=10 - ) - + handler = CallbackHandler(callback_url=url, session_id=session_id, secret=callback_secret, timeout=10) + self._callback_registry[callback_id] = { "url": url, "session_id": session_id, @@ -709,7 +679,7 @@ def _register_signal_callback(self, url, signal_name, signal_info, session_id, c "scope": "signal", "name": signal_name, "callback_type": signal_info, - "handler": handler # Store handler instance for reuse + "handler": handler, # Store handler instance for reuse } logger.debug(f"Registered signal callback {callback_id}: {signal_name} -> {url}") return callback_id @@ -718,11 +688,11 @@ def _value_to_rest(self, value, type_info): """ Convert a single value as received from Frida to how it should be exposed to REST Handles enums, objects, structs, and arrays. - + Args: value: The raw json value from Frida type_info: GITypeInfo that corresponds to the value - + Returns: Converted value """ @@ -731,7 +701,7 @@ def _value_to_rest(self, value, type_info): if tag == "interface": interface = GIRepository.type_info_get_interface(type_info) info_type = interface.get_type() - + if info_type == GIRepository.InfoType.ENUM or info_type == GIRepository.InfoType.FLAGS: full_name = f"{interface.get_namespace()}{interface.get_name()}" enum_mapping = self.enum_mappings.get(full_name, {}) @@ -748,24 +718,24 @@ def _value_to_rest(self, value, type_info): elif tag == "array": array_type = GIRepository.type_info_get_array_type(type_info) if array_type != GIRepository.ArrayType.C: - logger.warning(f"Unsupported array type") + logger.warning("Unsupported array type") return value element_type_info = GIRepository.type_info_get_param_type(type_info, 0) # Convert each element return [self._value_to_rest(item, element_type_info) for item in value] - + # No conversion needed return value def _arg_from_rest(self, rest_value, arg_info, headers): """ Convert an arg definition as received from REST to how Frida expects it - + Args: rest_value: The raw json value from REST arg_info: GIArgInfo that corresponds to the arguments being passed headers: The headers from the request - + Returns: Converted value """ @@ -777,7 +747,7 @@ def _arg_from_rest(self, rest_value, arg_info, headers): interface = GIRepository.type_info_get_interface(arg_type) info_type = interface.get_type() - + if info_type == GIRepository.InfoType.ENUM or info_type == GIRepository.InfoType.FLAGS: # Convert string enum name to integer value full_name = f"{interface.get_namespace()}{interface.get_name()}" @@ -796,23 +766,23 @@ def _convert_callback_args(self, args, cb_type): """ Convert callback arguments, especially enum integers to strings. Also translates 'this' to 'self' for REST API compatibility. - + Args: args: Dictionary of callback argument values from Frida cb_type: GICallableInfo for the callback type - + Returns: Dictionary with converted values """ converted_args = {} - + # Handle 'this' parameter (for signals/methods) if present # This is added manually by is_method=True but not in GIRepository's arg list if "this" in args: # Convert 'this' to 'self' and add as first parameter # The type for 'this' is always a pointer converted_args["self"] = {"ptr": args["this"]} - + # Process the introspected arguments n_args = GIRepository.callable_info_get_n_args(cb_type) for i in range(n_args): @@ -826,15 +796,15 @@ def _convert_callback_args(self, args, cb_type): arg_type = GIRepository.arg_info_get_type(arg_info) converted_args[arg_name] = self._value_to_rest(arg_value, arg_type) - + return converted_args - + def _type_to_json(self, t): """Convert GIRepository type to JSON type dict with name and subtype""" # Get the type tag tag_enum = GIRepository.type_info_get_tag(t) tag = GIRepository.type_tag_to_string(tag_enum) - + # Check if it's an array type if tag == "array": array_type = GIRepository.type_info_get_array_type(t) @@ -848,7 +818,7 @@ def _type_to_json(self, t): return {"name": "array", "subtype": subtype} # For other array types, treat as pointer for now return {"name": "pointer", "subtype": None} - + # Check if it's an interface type if tag == "interface": interface = GIRepository.type_info_get_interface(t) @@ -885,13 +855,13 @@ def _type_to_json(self, t): "utf8": "string", "gfloat": "float", "gdouble": "double", - "GType": "int64", # FIXME beware of this - "void": "void" + "GType": "int64", # FIXME beware of this + "void": "void", } json_type = type_map.get(tag, "pointer") return {"name": json_type, "subtype": None} - + def _arg_to_json(self, arg, is_method=False): """Convert argument info to JSON representation""" arg_type = GIRepository.arg_info_get_type(arg) @@ -919,9 +889,9 @@ def _arg_to_json(self, arg, is_method=False): "destroy": destroy, "is_destroy": False, "direction": GIRepository.arg_info_get_direction(arg), - "type": type_info + "type": type_info, } - + # Handle structs - check caller allocates if type_info["name"] == "struct": if ret["direction"] == GIRepository.Direction.OUT and GIRepository.arg_info_is_caller_allocates(arg): @@ -931,20 +901,15 @@ def _arg_to_json(self, arg, is_method=False): if type_info["name"] == "gtype": if ret["direction"] == GIRepository.Direction.OUT and GIRepository.arg_info_is_caller_allocates(arg): ret["direction"] = GIRepository.Direction.IN - + return ret - + def _callable_to_json(self, cb, is_method=False): """Convert callable info to JSON representation""" return_type_info = self._type_to_json(GIRepository.callable_info_get_return_type(cb)) - - ret = { - "arguments": [], - "is_method": is_method, - "returns": return_type_info, - "return_length": -1 - } - + + ret = {"arguments": [], "is_method": is_method, "returns": return_type_info, "return_length": -1} + if is_method: # Prepend self argument ra = { @@ -957,10 +922,10 @@ def _callable_to_json(self, cb, is_method=False): "is_destroy": False, "length": -1, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, } ret["arguments"].append(ra) - + # Add all arguments n_args = GIRepository.callable_info_get_n_args(cb) for i in range(n_args): @@ -974,7 +939,7 @@ def _callable_to_json(self, cb, is_method=False): arg = GIRepository.callable_info_get_arg(cb, i) arg_type = GIRepository.arg_info_get_type(arg) tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(arg_type)) - + if tag == "array": array_type = GIRepository.type_info_get_array_type(arg_type) if array_type == GIRepository.ArrayType.C: @@ -994,7 +959,7 @@ def _callable_to_json(self, cb, is_method=False): if length_idx >= 0: ret["arguments"][length_idx + offset]["skip_out"] = True ret["return_length"] = length_idx + offset - + # Mark skipped arguments for r in ret["arguments"]: if r["closure"] >= 0: @@ -1005,26 +970,26 @@ def _callable_to_json(self, cb, is_method=False): ret["arguments"][r["destroy"]]["is_destroy"] = True if r["direction"] == GIRepository.Direction.OUT: r["skip_in"] = True - + return ret - + def _method_to_json(self, method): """Generate complete method JSON representation""" flags = GIRepository.function_info_get_flags(method) is_method = bool(flags & GIRepository.FunctionInfoFlags.IS_METHOD) return self._callable_to_json(method, is_method=is_method) - + def _find_function_info(self, namespace, class_name, method_name): """Find function info from operation_id""" # operation_id format: {namespace}_{object_name}_{method_name} # or {namespace}__{function_name} for standalone functions - + # Search through the repository n_infos = self.repo.get_n_infos(namespace) for i in range(n_infos): info = self.repo.get_info(namespace, i) info_type = info.get_type() - + if info_type == GIRepository.InfoType.FUNCTION: # Standalone function: namespace__function_name if class_name is None and info.get_name() == method_name: @@ -1056,7 +1021,7 @@ def _find_function_info(self, namespace, class_name, method_name): method = GIRepository.enum_info_get_method(info, j) if method.get_name() == method_name: return method - + return None def _create_get_type_handler(self, type_info): @@ -1064,23 +1029,20 @@ def _create_get_type_handler(self, type_info): _type = { "arguments": [], "is_method": False, - "returns": {"name": "int64", "subtype": None} # FIXME beware of this + "returns": {"name": "int64", "subtype": None}, # FIXME beware of this } + async def get_type_handler(*args, **kwargs): if symbol == "intern": - result = await asyncio.to_thread( - self.scripts[0].exports_sync.internal_gtype, type_info.get_name() - ) - return { "return": result } + result = await asyncio.to_thread(self.scripts[0].exports_sync.internal_gtype, type_info.get_name()) + return {"return": result} else: # Serialize the command command = self.command_serializer.serialize_call(symbol, _type, []) - + # Execute command (handles correlation ID internally) - result = await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + result = await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return result return get_type_handler @@ -1094,40 +1056,37 @@ def _create_generic_new_handler(self, type_info): # For non-struct types, we might need a different approach # For now, assume a default size or handle differently size = 0 # This might need specific handling per type - + async def generic_new_handler(*args, **kwargs): # Serialize the command command = self.command_serializer.serialize_alloc(size) - + # Execute command (handles correlation ID internally) - result = await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + result = await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return {"return": {"ptr": result}} - + return generic_new_handler - + def _create_generic_free_handler(self, type_info): """Create handler for generic struct/object deallocation""" + async def generic_free_handler(*args, **kwargs): # Extract the self parameter (pointer to free) - obj = kwargs.get('self') + obj = kwargs.get("self") if obj is None: raise ValueError("Missing 'self' parameter for free operation") - if not "ptr" in obj: + if "ptr" not in obj: raise ValueError("Missing 'ptr' value") # Serialize the command command = self.command_serializer.serialize_free(obj["ptr"]) - + # Execute command (handles correlation ID internally) - await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return None - + return generic_free_handler def _create_generic_ref_handler(self, type_info): @@ -1135,17 +1094,18 @@ def _create_generic_ref_handler(self, type_info): # Determine the ref function symbol based on the type namespace = type_info.get_namespace() name = type_info.get_name() - + # Get the C symbol prefix from GIRepository # e.g., "G" for GObject namespace -> g_param_spec_ref c_prefix = self.repo.get_c_prefix(namespace) symbol_prefix = c_prefix.lower() - + # Convert CamelCase to snake_case import re - snake_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + snake_name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() symbol = f"{symbol_prefix}_{snake_name}_ref" - + # Build the JSON representation for Frida _type = { "arguments": [ @@ -1158,33 +1118,29 @@ def _create_generic_ref_handler(self, type_info): "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, } ], "is_method": True, - "returns": {"name": "pointer", "subtype": None} + "returns": {"name": "pointer", "subtype": None}, } - + async def generic_ref_handler(*args, **kwargs): # Extract the self parameter (object pointer) - obj = kwargs.get('self') + obj = kwargs.get("self") if obj is None: raise ValueError("Missing 'self' parameter for ref operation") - if not "ptr" in obj: + if "ptr" not in obj: raise ValueError("Missing 'ptr' value") # Serialize the command - command = self.command_serializer.serialize_call( - symbol, _type, [obj["ptr"]] - ) - + command = self.command_serializer.serialize_call(symbol, _type, [obj["ptr"]]) + # Execute command (handles correlation ID internally) - result = await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + result = await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return {"return": {"ptr": result["return"]}} - + return generic_ref_handler def _create_generic_unref_handler(self, type_info): @@ -1192,17 +1148,18 @@ def _create_generic_unref_handler(self, type_info): # Determine the unref function symbol based on the type namespace = type_info.get_namespace() name = type_info.get_name() - + # Get the C symbol prefix from GIRepository # e.g., "G" for GObject namespace -> g_param_spec_unref c_prefix = self.repo.get_c_prefix(namespace) symbol_prefix = c_prefix.lower() - + # Convert CamelCase to snake_case import re - snake_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + snake_name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() symbol = f"{symbol_prefix}_{snake_name}_unref" - + # Build the JSON representation for Frida _type = { "arguments": [ @@ -1215,58 +1172,54 @@ def _create_generic_unref_handler(self, type_info): "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, } ], "is_method": True, - "returns": {"name": "void", "subtype": None} + "returns": {"name": "void", "subtype": None}, } - + async def generic_unref_handler(*args, **kwargs): # Extract the self parameter (object pointer) - obj = kwargs.get('self') + obj = kwargs.get("self") if obj is None: raise ValueError("Missing 'self' parameter for unref operation") - if not "ptr" in obj: + if "ptr" not in obj: raise ValueError("Missing 'ptr' value") # Serialize the command - command = self.command_serializer.serialize_call( - symbol, _type, [obj["ptr"]] - ) - + command = self.command_serializer.serialize_call(symbol, _type, [obj["ptr"]]) + # Execute command (handles correlation ID internally) - await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return None - + return generic_unref_handler def _parse_response(self, result, operation, field_type_info=None, method_info=None): """ Parse and transform the response from Frida based on OpenAPI schema. - + Args: result: The raw result from Frida operation: The OpenAPI operation object field_type_info: Optional GITypeInfo for field operations method_info: Optional GICallableInfo for method operations (used for enum conversion) - + Returns: Transformed result with proper type conversions """ if not result: return result - + # Ok, now we have a response, check the spec about the response type to see if # there are any structs or objects for k, v in result.items(): responses = operation.responses # Take into account that the endpoint is already resolved k_def = responses["200"]["content"]["application/json"]["schema"]["properties"][k] - + # Handle arrays - check if items are objects/structs using GI introspection if "type" in k_def and k_def["type"] == "array" and isinstance(v, list): # Use GI introspection to determine the array element type @@ -1274,15 +1227,17 @@ def _parse_response(self, result, operation, field_type_info=None, method_info=N if method_info and not field_type_info: # For method calls, get return type from method info type_info = GIRepository.callable_info_get_return_type(method_info) - + if type_info: tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(type_info)) if tag == "array": # Get the element type of the array element_type_info = GIRepository.type_info_get_param_type(type_info, 0) if element_type_info: - element_tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(element_type_info)) - + element_tag = GIRepository.type_tag_to_string( + GIRepository.type_info_get_tag(element_type_info) + ) + # Check if element is an interface (object/struct/enum) if element_tag == "interface": interface = GIRepository.type_info_get_interface(element_type_info) @@ -1290,9 +1245,11 @@ def _parse_response(self, result, operation, field_type_info=None, method_info=N info_type = interface.get_type() # Convert objects and structs to {ptr: "0x..."} format if info_type in [GIRepository.InfoType.OBJECT, GIRepository.InfoType.STRUCT]: - logger.debug(f"Converting array of {interface.get_name()} objects to {{ptr: ...}} format") + logger.debug( + f"Converting array of {interface.get_name()} objects to {{ptr: ...}} format" + ) result[k] = [{"ptr": item} if isinstance(item, str) else item for item in v] - + # Handle single object/struct values if "x-gi-type" in k_def and k_def["x-gi-type"] in ["object", "struct", "gtype"]: result[k] = {"ptr": v} @@ -1305,7 +1262,7 @@ def _parse_response(self, result, operation, field_type_info=None, method_info=N if method_info and not field_type_info: # For method calls, get return type from method info type_info = GIRepository.callable_info_get_return_type(method_info) - + if type_info: interface = GIRepository.type_info_get_interface(type_info) full_name = f"{interface.get_namespace()}{interface.get_name()}" @@ -1320,63 +1277,57 @@ def _parse_response(self, result, operation, field_type_info=None, method_info=N def _create_field_get_handler(self, offset, field_type_json, field_type_info, operation): """Create handler for reading a struct field""" + async def field_get_handler(*args, **kwargs): # Extract the self parameter (struct pointer) - obj = kwargs.get('self') + obj = kwargs.get("self") if obj is None: raise ValueError("Missing 'self' parameter for field get") - if not "ptr" in obj: + if "ptr" not in obj: raise ValueError("Missing 'ptr' value") - + # Serialize the command - command = self.command_serializer.serialize_get_field( - obj["ptr"], offset, field_type_json - ) - + command = self.command_serializer.serialize_get_field(obj["ptr"], offset, field_type_json) + # Execute command (handles correlation ID internally) - raw_result = await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + raw_result = await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + # Wrap in result dictionary result = {"return": raw_result} - + # Use common response parsing logic return self._parse_response(result, operation, field_type_info) - + return field_get_handler def _create_field_put_handler(self, offset, field_type_json, field_type_info, operation): """Create handler for writing a struct field""" + async def field_put_handler(*args, **kwargs): # Extract the self parameter (struct pointer) - obj = kwargs.get('self') + obj = kwargs.get("self") if obj is None: raise ValueError("Missing 'self' parameter for field put") - if not "ptr" in obj: + if "ptr" not in obj: raise ValueError("Missing 'ptr' value") - + # Extract the value to write - value = kwargs.get('value') + value = kwargs.get("value") if value is None: raise ValueError("Missing 'value' parameter for field put") - + # Handle object/struct values (extract ptr) - if isinstance(value, dict) and 'ptr' in value: - value = value['ptr'] - + if isinstance(value, dict) and "ptr" in value: + value = value["ptr"] + # Serialize the command - command = self.command_serializer.serialize_set_field( - obj["ptr"], offset, field_type_json, value - ) - + command = self.command_serializer.serialize_set_field(obj["ptr"], offset, field_type_json, value) + # Execute command (handles correlation ID internally) - await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return None - + return field_put_handler # Custom methods @@ -1399,42 +1350,38 @@ async def func(*args, **kwargs): "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, } _type["arguments"].append(ra) - + # Serialize the command - command = self.command_serializer.serialize_call( - "g_list_free", _type, list(converted_kwargs.values()) - ) - + command = self.command_serializer.serialize_call("g_list_free", _type, list(converted_kwargs.values())) + # Execute command (handles correlation ID internally) - await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return func def _create_signal_connect_handler(self, namespace, class_name, signal_name, operation): """Create handler for connecting to a signal via g_signal_connect_data""" - + # Find the object info using find_by_name object_info = self.repo.find_by_name(namespace, class_name) if not object_info or object_info.get_type() != GIRepository.InfoType.OBJECT: raise ValueError(f"Could not find object {namespace}.{class_name}") - + # Find the signal info using find_signal signal_info = GIRepository.object_info_find_signal(object_info, signal_name) if not signal_info: raise ValueError(f"Could not find signal '{signal_name}' on {namespace}.{class_name}") - + # Get GObjectConnectFlags type info for proper conversion - connect_flags_info = self.repo.find_by_name('GObject', 'ConnectFlags') - + connect_flags_info = self.repo.find_by_name("GObject", "ConnectFlags") + # Generate the signal signature for Frida # Signals are like methods with the instance as the first parameter signal_signature = self._callable_to_json(signal_info, is_method=True) - + # Manually create the signature for g_signal_connect_data # gulong g_signal_connect_data(gpointer instance, const gchar *detailed_signal, # GCallback c_handler, gpointer data, @@ -1450,7 +1397,7 @@ def _create_signal_connect_handler(self, namespace, class_name, signal_name, ope "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, }, { "name": "detailed_signal", @@ -1461,7 +1408,7 @@ def _create_signal_connect_handler(self, namespace, class_name, signal_name, ope "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "string", "subtype": None} + "type": {"name": "string", "subtype": None}, }, { "name": "c_handler", @@ -1475,8 +1422,8 @@ def _create_signal_connect_handler(self, namespace, class_name, signal_name, ope "type": { "name": "callback", "subtype": signal_signature, - "scope": "signal" # Mark as non-blocking signal callback - } + "scope": "signal", # Mark as non-blocking signal callback + }, }, { "name": "data", @@ -1487,7 +1434,7 @@ def _create_signal_connect_handler(self, namespace, class_name, signal_name, ope "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, }, { "name": "destroy_data", @@ -1498,7 +1445,7 @@ def _create_signal_connect_handler(self, namespace, class_name, signal_name, ope "destroy": -1, "is_destroy": True, "direction": GIRepository.Direction.IN, - "type": {"name": "pointer", "subtype": None} + "type": {"name": "pointer", "subtype": None}, }, { "name": "connect_flags", @@ -1509,127 +1456,119 @@ def _create_signal_connect_handler(self, namespace, class_name, signal_name, ope "destroy": -1, "is_destroy": False, "direction": GIRepository.Direction.IN, - "type": {"name": "int32", "subtype": None} - } + "type": {"name": "int32", "subtype": None}, + }, ], "is_method": False, - "returns": {"name": "uint64", "subtype": None} # gulong return type + "returns": {"name": "uint64", "subtype": None}, # gulong return type } - + async def signal_connect_handler(*args, **kwargs): # Debug: Print what we're receiving logger.debug(f"signal_connect_handler called with args={args}, kwargs={kwargs}") - + # Extract the self parameter (object instance) - obj = kwargs.get('self') + obj = kwargs.get("self") if obj is None: raise ValueError("Missing 'self' parameter for signal connection") if "ptr" not in obj: raise ValueError("Missing 'ptr' value") - + instance_ptr = obj["ptr"] - + # Get connection parameters - Connexion may pass body as 'body' kwarg or individual fields # Try getting from individual kwargs first (Connexion unpacks requestBody) - flags_str = kwargs.get('flags', 'default') - handler_url = kwargs.get('handler') - + flags_str = kwargs.get("flags", "default") + handler_url = kwargs.get("handler") + # If not in kwargs, try getting from body parameter or connexion.request if handler_url is None: import connexion - body = kwargs.get('body') or connexion.request.json or {} + + body = kwargs.get("body") or connexion.request.json or {} if isinstance(body, dict): - flags_str = body.get('flags', flags_str) - handler_url = body.get('handler') - + flags_str = body.get("flags", flags_str) + handler_url = body.get("handler") + if not handler_url: raise ValueError("Missing 'handler' callback URL in request body") - + # Get headers for callback authentication headers = connexion.request.headers - session_id = headers.get('session-id') - callback_secret = headers.get('callback-secret') - + session_id = headers.get("session-id") + callback_secret = headers.get("callback-secret") + # Convert GObjectConnectFlags from string to integer using enum_mappings full_name = f"{connect_flags_info.get_namespace()}{connect_flags_info.get_name()}" flags_mapping = self.enum_mappings.get(full_name, {}) connect_flags = flags_mapping.get(flags_str, 0) - + # Register the callback with the signal signature for proper marshalling callback_id = self._register_signal_callback( - handler_url, - signal_name, - signal_info, - session_id, - callback_secret + handler_url, signal_name, signal_info, session_id, callback_secret ) - + # Prepare arguments for g_signal_connect_data args = [ instance_ptr, # instance as a pointer string, not an object signal_name, callback_id, # c_handler - Frida will create GCallback for this - connect_flags + connect_flags, ] - + # Serialize the command - command = self.command_serializer.serialize_call( - 'g_signal_connect_data', - connect_func_signature, - args - ) - + command = self.command_serializer.serialize_call("g_signal_connect_data", connect_func_signature, args) + # Execute command (handles correlation ID internally) - result = await asyncio.to_thread( - self.message_bus.execute, command, connexion.request.headers - ) - + result = await asyncio.to_thread(self.message_bus.execute, command, connexion.request.headers) + return result - + return signal_connect_handler # TODO what to access here, the info from GI (_method) or the type info for Frida (_type) def create_frida_handler(self): """Create handler that calls Frida with the method JSON, converting enum strings to integers, - objects to pointers, etc. + objects to pointers, etc. """ + async def frida_resolver_handler(_method=None, _type=None, _endpoint=None, *args, **kwargs): """Call Frida to the actual call the symbol - The method receives the GI's BaseInfo (_method) - The JSON representation of the GI information (_type) - The OpenAPI endpoint entry + The method receives the GI's BaseInfo (_method) + The JSON representation of the GI information (_type) + The OpenAPI endpoint entry """ # Get the symbol from the method info symbol = GIRepository.function_info_get_symbol(_method) # Headers headers = connexion.request.headers - + # Check for correlation ID (reentrant call from callback) - correlation_id = headers.get('X-Correlation-Id') - + correlation_id = headers.get("X-Correlation-Id") + # Check for async execution preference - prefer_header = headers.get('Prefer', '') - is_async_requested = 'respond-async' in prefer_header - + prefer_header = headers.get("Prefer", "") + is_async_requested = "respond-async" in prefer_header + # Determine if this is a void function using _type (JSON representation) # Check return type - returns is a dict like {"name": "void", "subtype": None} returns_void = _type.get("returns", {}).get("name") == "void" - + # Check for output parameters has_out_params = any( arg.get("direction") in [GIRepository.Direction.OUT, GIRepository.Direction.INOUT] for arg in _type.get("arguments", []) ) - + is_true_void = returns_void and not has_out_params - + # If async execution is requested but function is not void, reject it if is_async_requested and not is_true_void: return { "error": "Prefer: respond-async is only supported for void functions (no return value, no output parameters)" }, 400 - + # Convert enum string values to integers before calling Frida converted_kwargs = {} n_args = GIRepository.callable_info_get_n_args(_method) @@ -1644,13 +1583,13 @@ async def frida_resolver_handler(_method=None, _type=None, _endpoint=None, *args for i in range(n_args): arg = GIRepository.callable_info_get_arg(_method, i) arg_name = arg.get_name() - + # Some args might not be on the passed in args, like output params if arg_name in kwargs: converted_kwargs[arg_name] = self._arg_from_rest(kwargs[arg_name], arg, headers) # In SSE Mode, the callback is never passed in REST, but we # need to generate the callback information - elif self.sse_only and _type["arguments"][i]["is_destroy"] == False: + elif self.sse_only and not _type["arguments"][i]["is_destroy"]: arg_type = GIRepository.arg_info_get_type(arg) tag = GIRepository.type_tag_to_string(GIRepository.type_info_get_tag(arg_type)) if tag != "interface": @@ -1660,14 +1599,12 @@ async def frida_resolver_handler(_method=None, _type=None, _endpoint=None, *args if info_type == GIRepository.InfoType.CALLBACK: callback_id = self._generate_callback(None, arg, info_type, headers) callbacks[arg_name] = converted_kwargs[arg_name] = callback_id - + # Serialize the command command = self.command_serializer.serialize_call( - symbol=symbol, - method_info=_type, - args=list(converted_kwargs.values()) + symbol=symbol, method_info=_type, args=list(converted_kwargs.values()) ) - + # Handle async execution (fire-and-forget) if is_async_requested and is_true_void: # For async execution WITH correlation ID: @@ -1685,21 +1622,15 @@ async def frida_resolver_handler(_method=None, _type=None, _endpoint=None, *args # Async direct execution (no thread affinity) async def execute_async(): try: - await asyncio.to_thread( - self.message_bus.execute_direct, command - ) + await asyncio.to_thread(self.message_bus.execute_direct, command) except Exception as e: logger.error(f"Async execution failed for {symbol}: {e}") - + asyncio.create_task(execute_async()) return "", 202, {"Preference-Applied": "respond-async"} - + # Execute command via appropriate channel (synchronous execution) - result = await asyncio.to_thread( - self.message_bus.execute, - command, - headers - ) + result = await asyncio.to_thread(self.message_bus.execute, command, headers) # Use common response parsing logic result = self._parse_response(result, _endpoint, method_info=_method) @@ -1712,7 +1643,6 @@ async def execute_async(): return result - return frida_resolver_handler def get_function_from_operation(self, operation): @@ -1731,19 +1661,19 @@ def get_function_from_operation(self, operation): return None namespace, class_name, method_name, operator = parsed - + # Check if this is a signal connection operation (operator == 'connect') - if operator == 'connect': + if operator == "connect": # This is a signal connection: {Namespace}-{ClassName}-{signal_name}-connect # method_name contains the signal name with underscores (e.g., 'sync_message') - signal_name = method_name.replace('_', '-') # Convert back to signal name format + signal_name = method_name.replace("_", "-") # Convert back to signal name format return self._create_signal_connect_handler(namespace, class_name, signal_name, operation) - + # Check if this is a field operation based on the operator - if operator in ['get', 'put']: + if operator in ["get", "put"]: # This is a field access operation field_name = method_name - + # Find the struct info struct_info = None n_infos = self.repo.get_n_infos(namespace) @@ -1752,7 +1682,7 @@ def get_function_from_operation(self, operation): if info.get_type() == GIRepository.InfoType.STRUCT and info.get_name() == class_name: struct_info = info break - + if struct_info: # Find the field info n_fields = GIRepository.struct_info_get_n_fields(struct_info) @@ -1765,14 +1695,18 @@ def get_function_from_operation(self, operation): is_writable = bool(field_flags & GIRepository.FieldInfoFlags.WRITABLE) field_type_info = GIRepository.field_info_get_type(field_info) field_type_json = self._type_to_json(field_type_info) - + if operator == "get": - return self._create_field_get_handler(field_offset, field_type_json, field_type_info, operation) + return self._create_field_get_handler( + field_offset, field_type_json, field_type_info, operation + ) elif operator == "put" and is_writable: - return self._create_field_put_handler(field_offset, field_type_json, field_type_info, operation) - + return self._create_field_put_handler( + field_offset, field_type_json, field_type_info, operation + ) + return None - + method_info = self._find_function_info(namespace, class_name, method_name) if method_info: # Generate the JSON representation @@ -1784,10 +1718,10 @@ def get_function_from_operation(self, operation): return ret # Custom cases when a function is not exported by GI # In the case of GLibList the free function is not exported by GI, so we need to create it manually - elif method_name == 'free' and namespace == 'GLib' and class_name == 'List': + elif method_name == "free" and namespace == "GLib" and class_name == "List": return self.custom_glib_list_free() # Check for the artificial methods - elif method_name in ['new', 'free', 'get_type', 'ref', 'unref']: + elif method_name in ["new", "free", "get_type", "ref", "unref"]: # Try to find the info (struct, object, enum, or flags) type_info = None n_infos = self.repo.get_n_infos(namespace) @@ -1795,29 +1729,34 @@ def get_function_from_operation(self, operation): info = self.repo.get_info(namespace, i) info_type = info.get_type() # Check for struct, object, enum, or flags that match the class name - if (info_type in [GIRepository.InfoType.STRUCT, - GIRepository.InfoType.OBJECT, - GIRepository.InfoType.ENUM, - GIRepository.InfoType.FLAGS] and - info.get_name() == class_name): + if ( + info_type + in [ + GIRepository.InfoType.STRUCT, + GIRepository.InfoType.OBJECT, + GIRepository.InfoType.ENUM, + GIRepository.InfoType.FLAGS, + ] + and info.get_name() == class_name + ): type_info = info break if type_info: - if method_name == 'new': + if method_name == "new": return self._create_generic_new_handler(type_info) - elif method_name == 'free': + elif method_name == "free": return self._create_generic_free_handler(type_info) - elif method_name == 'get_type': + elif method_name == "get_type": return self._create_get_type_handler(type_info) - elif method_name == 'ref': + elif method_name == "ref": return self._create_generic_ref_handler(type_info) - elif method_name == 'unref': + elif method_name == "unref": return self._create_generic_unref_handler(type_info) else: logger.error(f"Type info found {type_info.get_name()} but not method {method_name}") return None - + logger.error(f"Type info not found {class_name} for {operation_id}") return None else: diff --git a/girest/girest/uri_parser.py b/girest/girest/uri_parser.py index 2e6b640..a60cac7 100644 --- a/girest/girest/uri_parser.py +++ b/girest/girest/uri_parser.py @@ -8,7 +8,7 @@ """ import logging -from typing import Any, Dict, List +from typing import Any, Dict import uritemplate from connexion.uri_parsing import OpenAPIURIParser @@ -19,7 +19,7 @@ class URITemplateParser(OpenAPIURIParser): """ URI parser that uses the uritemplate library to parse complex URI patterns. - + This parser inherits from OpenAPIURIParser and overrides the parameter resolution to use URI templates for more robust parsing of query and path parameters, particularly when dealing with object types and complex serialization formats. @@ -28,7 +28,7 @@ class URITemplateParser(OpenAPIURIParser): def __init__(self, param_defns, body_defn): """ Initialize the URI template parser. - + :param param_defns: List of parameter definitions from the OpenAPI spec :param body_defn: Body definition from the OpenAPI spec """ @@ -39,24 +39,24 @@ def __init__(self, param_defns, body_defn): def _build_uri_templates(self) -> Dict[str, uritemplate.URITemplate]: """ Build URI templates for each parameter based on their style and explode settings. - + :return: Dictionary mapping parameter names to URI templates """ templates = {} - + for param_name, param_defn in self._param_defns.items(): param_in = param_defn.get("in") - + # Only build templates for query and path parameters if param_in not in ["query", "path"]: continue - + # Get style and explode from parameter definition default_style = self.style_defaults.get(param_in, "simple") style = param_defn.get("style", default_style) is_form = style == "form" explode = param_defn.get("explode", is_form) - + # Build the appropriate template syntax based on style and explode if style == "form" and explode: # Form style with explode expands object properties @@ -69,56 +69,56 @@ def _build_uri_templates(self) -> Dict[str, uritemplate.URITemplate]: else: # Default simple style template_str = f"{{{param_name}}}" - + try: templates[param_name] = uritemplate.URITemplate(template_str) except Exception as e: logger.warning(f"Failed to create URI template for {param_name}: {e}") - + return templates def _is_object_schema(self, param_schema: Dict) -> bool: """ Check if a parameter schema represents an object type. - + This includes: - Schemas with type="object" - Schemas with allOf/anyOf/oneOf (complex schemas) - Schemas with $ref to object types - + :param param_schema: The schema to check :return: True if it's an object schema """ if not param_schema: return False - + # Direct object type if param_schema.get("type") == "object": return True - + # Complex schemas (allOf, anyOf, oneOf) if any(key in param_schema for key in ["allOf", "anyOf", "oneOf"]): return True - + # Schemas with $ref are treated as potentially objects # We can't easily resolve the ref here, so we'll try to parse it if "$ref" in param_schema: return True - + return False - + def _parse_object_from_string(self, value: str, param_defn: Dict, _in: str) -> Any: """ Parse an object from its serialized string representation. - + According to OpenAPI spec: - style=simple, explode=false (default for path): "prop1,val1,prop2,val2" - style=form, explode=false (used for query): "param=prop1,val1,prop2,val2" - + Since GIRest objects always have a single "ptr" property, the format is: - path: "ptr,value" - query: "param=ptr,value" - + :param value: Serialized string value :param param_defn: Parameter definition :param _in: Parameter location (path/query) @@ -126,13 +126,13 @@ def _parse_object_from_string(self, value: str, param_defn: Dict, _in: str) -> A """ if not isinstance(value, str): return value - + # Get style and explode settings default_style = self.style_defaults.get(_in, "simple") style = param_defn.get("style", default_style) is_form = style == "form" explode = param_defn.get("explode", is_form) - + # For style=simple or style=form with explode=false # Object format is "prop1,val1,prop2,val2,..." if not explode and "," in value: @@ -143,14 +143,14 @@ def _parse_object_from_string(self, value: str, param_defn: Dict, _in: str) -> A for i in range(0, len(parts), 2): obj[parts[i]] = parts[i + 1] return obj - + # If it doesn't match the pattern, return as-is return value - + def _parse_with_template(self, param_name: str, value: Any, param_defn: Dict, _in: str) -> Any: """ Parse a parameter value using URI template extraction if available. - + :param param_name: Name of the parameter :param value: Raw value from the request :param param_defn: Parameter definition from the spec @@ -160,23 +160,23 @@ def _parse_with_template(self, param_name: str, value: Any, param_defn: Dict, _i # For string values, try to parse as serialized objects if isinstance(value, str): return self._parse_object_from_string(value, param_defn, _in) - + # For other types, return as-is return value def resolve_params(self, params, _in): """ Resolve parameters using URI template aware parsing. - + This method extends the parent class to use URI templates for better handling of complex parameter types. - + :param params: Dictionary of raw parameter values :param _in: Parameter location (query, path, etc.) :return: Resolved parameters """ resolved_param = {} - + for k, values in params.items(): param_defn = self.param_defns.get(k) param_schema = self.param_schemas.get(k) @@ -192,7 +192,7 @@ def resolve_params(self, params, _in): # Check if this is an object type with allOf/anyOf/oneOf or $ref is_complex_schema = self._is_object_schema(param_schema) - + # Handle array types if param_schema and param_schema.get("type") == "array": # resolve variable re-assignment, handle explode @@ -203,7 +203,7 @@ def resolve_params(self, params, _in): elif is_complex_schema: # Extract the value from list if needed value_to_parse = values[-1] if isinstance(values, list) else values - + # For objects, use template-based parsing parsed_value = self._parse_with_template(k, value_to_parse, param_defn, _in) resolved_param[k] = parsed_value @@ -213,11 +213,10 @@ def resolve_params(self, params, _in): # Type coercion is handled by parent class try: - from connexion.utils import coerce_type from connexion.exceptions import TypeValidationError - resolved_param[k] = coerce_type( - param_defn, resolved_param[k], "parameter", k - ) + from connexion.utils import coerce_type + + resolved_param[k] = coerce_type(param_defn, resolved_param[k], "parameter", k) except TypeValidationError: pass diff --git a/girest/girest/utils.py b/girest/girest/utils.py index a6c0524..9a2e6a7 100644 --- a/girest/girest/utils.py +++ b/girest/girest/utils.py @@ -5,53 +5,53 @@ def parse_operation_id(operation_id): """Parse operation_id into namespace, class/struct name, method/field name, and optional operator. - + Args: operation_id: The operation ID string to parse (format: namespace-class-method or namespace-class-field-operator) - + Returns: tuple: (namespace, class_name, method_name, operator) or None if invalid format For standalone functions: (namespace, None, method_name, None) For methods: (namespace, class_name, method_name, None) For field operations: (namespace, class_name, field_name, operator) - + operator is 'get' or 'put' for field operations, None otherwise - + Examples: >>> parse_operation_id("Gst-Buffer-new") ('Gst', 'Buffer', 'new', None) - + >>> parse_operation_id("Gst-Buffer-pts-get") ('Gst', 'Buffer', 'pts', 'get') - + >>> parse_operation_id("Gst-Buffer-pts-put") ('Gst', 'Buffer', 'pts', 'put') - + >>> parse_operation_id("Gst--version") ('Gst', None, 'version', None) - + >>> parse_operation_id("invalid") None """ if not operation_id: return None - - parts = operation_id.split('-') + + parts = operation_id.split("-") if len(parts) < 2: return None - + if len(parts) == 4: # Field operation: namespace-class-field-operator class_name = parts[1] if parts[1] else None return (parts[0], class_name, parts[2], parts[3]) - + if len(parts) == 3: # Method: namespace-class-method class_name = parts[1] if parts[1] else None return (parts[0], class_name, parts[2], None) - + if len(parts) == 2: # Standalone function: namespace-function return (parts[0], None, parts[1], None) - + return None diff --git a/girest/girest/validators.py b/girest/girest/validators.py index 8d7bf6f..cbf2859 100644 --- a/girest/girest/validators.py +++ b/girest/girest/validators.py @@ -6,11 +6,10 @@ """ import logging -from typing import Any, Dict, Optional - -from jsonschema import Draft4Validator, ValidationError, validators +from typing import Dict from connexion.validators.parameter import ParameterValidator +from jsonschema import Draft4Validator, ValidationError logger = logging.getLogger("girest.validators") @@ -18,7 +17,7 @@ class GIRestParameterValidator(ParameterValidator): """ Enhanced parameter validator that properly handles allOf, anyOf, and oneOf schemas. - + This validator extends Connexion's ParameterValidator to support JSON Schema composition keywords (allOf, anyOf, oneOf) which are commonly used in the GIRest schema for representing GObject type hierarchies. @@ -28,7 +27,7 @@ class GIRestParameterValidator(ParameterValidator): def _create_validator_with_defaults(schema: Dict) -> Draft4Validator: """ Create a JSON Schema validator that properly handles composition keywords. - + :param schema: The JSON schema to validate against :return: A configured Draft4Validator instance """ @@ -38,6 +37,7 @@ def _create_validator_with_defaults(schema: Dict) -> Draft4Validator: format_checker = Draft4Validator.FORMAT_CHECKER # type: ignore except AttributeError: # jsonschema < 4.5.0 from jsonschema import draft4_format_checker + format_checker = draft4_format_checker return Draft4Validator(schema, format_checker=format_checker) @@ -46,35 +46,33 @@ def _create_validator_with_defaults(schema: Dict) -> Draft4Validator: def validate_parameter(parameter_type, value, param, param_name=None): """ Validate a parameter value against its schema, with support for allOf/anyOf/oneOf. - + :param parameter_type: Type of parameter (query, path, header, cookie) :param value: The value to validate :param param: The parameter definition from the spec :param param_name: Optional parameter name for error messages :return: Error message if validation fails, None otherwise """ - from connexion.utils import is_nullable, is_null - + from connexion.utils import is_null, is_nullable + if is_nullable(param) and is_null(value): return elif value is not None: import copy + param = copy.deepcopy(param) param_schema = param.get("schema", param) - + try: # Use our enhanced validator that handles composition keywords - validator = GIRestParameterValidator._create_validator_with_defaults( - param_schema - ) + validator = GIRestParameterValidator._create_validator_with_defaults(param_schema) validator.validate(value) except ValidationError as exception: # Provide more detailed error messages for composition keywords if any(keyword in param_schema for keyword in ["allOf", "anyOf", "oneOf"]): logger.debug( - f"Validation failed for {parameter_type} parameter with " - f"composition schema: {exception}" + f"Validation failed for {parameter_type} parameter with " f"composition schema: {exception}" ) return str(exception) diff --git a/girest/poetry.lock b/girest/poetry.lock index 4d19826..f9f41c1 100644 --- a/girest/poetry.lock +++ b/girest/poetry.lock @@ -296,6 +296,18 @@ files = [ {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] +[[package]] +name = "cfgv" +version = "3.5.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, + {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -482,6 +494,18 @@ mock = ["jsf (>=0.10.0)"] swagger-ui = ["swagger-ui-bundle (>=1.1.0)"] uvicorn = ["uvicorn[standard] (>=0.17.6)"] +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -501,6 +525,18 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "filelock" +version = "3.25.2" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70"}, + {file = "filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"}, +] + [[package]] name = "flask" version = "3.1.2" @@ -806,6 +842,21 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "identify" +version = "2.6.17" +description = "File identification library for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0"}, + {file = "identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.11" @@ -1170,6 +1221,18 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} +[[package]] +name = "nodeenv" +version = "1.10.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, + {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, +] + [[package]] name = "packaging" version = "25.0" @@ -1182,6 +1245,18 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"}, + {file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1198,6 +1273,25 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pre-commit" +version = "4.5.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77"}, + {file = "pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "propcache" version = "0.4.1" @@ -1434,6 +1528,26 @@ pytest = ">=7.0.0,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "python-discovery" +version = "1.1.3" +description = "Python interpreter discovery" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e"}, + {file = "python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5"}, +] + +[package.dependencies] +filelock = ">=3.15.4" +platformdirs = ">=4.3.6,<5" + +[package.extras] +docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -1467,7 +1581,7 @@ version = "6.0.3" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, @@ -1748,6 +1862,34 @@ files = [ {file = "rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8"}, ] +[[package]] +name = "ruff" +version = "0.15.6" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff"}, + {file = "ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3"}, + {file = "ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e"}, + {file = "ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb"}, + {file = "ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0"}, + {file = "ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c"}, + {file = "ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406"}, + {file = "ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837"}, + {file = "ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1982,6 +2124,25 @@ dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] +[[package]] +name = "virtualenv" +version = "21.2.0" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f"}, + {file = "virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} +platformdirs = ">=3.9.1,<5" +python-discovery = ">=1" +typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""} + [[package]] name = "watchfiles" version = "1.1.1" @@ -2349,4 +2510,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "13522c394619a20aeefb1b07f6be32ad0ef13a2f9b0c398bd9f5b89ce86d3a0a" +content-hash = "806526ef8dc2d19652a44eefee84229e0ffa6901e397bd50e06eae6c8d92b13f" diff --git a/girest/pyproject.toml b/girest/pyproject.toml index a031c19..3f18011 100644 --- a/girest/pyproject.toml +++ b/girest/pyproject.toml @@ -8,7 +8,7 @@ include = ["girest.js"] [tool.poetry.dependencies] python = "^3.10" -connexion = { version = "^3.3.0", extras = ["flask", "swagger-ui","uvicorn"]} +connexion = { version = "^3.3.0", extras = ["flask", "swagger-ui", "uvicorn"] } frida = "*" pygobject = "<3.50.0" apispec = "^6.8.4" @@ -19,6 +19,8 @@ aiohttp = "^3.13.2" pytest = "^8.0.0" pytest-asyncio = "^0.23.0" httpx = "^0.27.0" +ruff = "*" +pre-commit = "*" [tool.pytest.ini_options] testpaths = ["tests"] @@ -31,3 +33,11 @@ asyncio_default_fixture_loop_scope = "function" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +extend-select = ["FA102", "I"] +ignore = ["E722"] diff --git a/girest/tests/conftest.py b/girest/tests/conftest.py index f3584cd..d6374e3 100644 --- a/girest/tests/conftest.py +++ b/girest/tests/conftest.py @@ -3,26 +3,27 @@ Pytest configuration and fixtures for GIRest tests. """ -import sys import os +import sys + import pytest # Add parent directory to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from girest.main import GIRest from girest.generator import TypeScriptGenerator +from girest.main import GIRest @pytest.fixture def gst_schema(): """ Generate OpenAPI schema for Gst namespace. - + Returns: dict: OpenAPI schema dictionary for Gst 1.0 """ - girest = GIRest('Gst', '1.0') + girest = GIRest("Gst", "1.0") spec = girest.generate() return spec.to_dict() @@ -31,11 +32,11 @@ def gst_schema(): def gobject_schema(): """ Generate OpenAPI schema for GObject namespace. - + Returns: dict: OpenAPI schema dictionary for GObject 2.0 """ - girest = GIRest('GObject', '2.0') + girest = GIRest("GObject", "2.0") spec = girest.generate() return spec.to_dict() @@ -44,14 +45,14 @@ def gobject_schema(): def gst_typescript(gst_schema): """ Generate TypeScript bindings for Gst namespace. - + Args: gst_schema: OpenAPI schema fixture for Gst namespace - + Returns: str: Generated TypeScript code """ - ts_gen = TypeScriptGenerator(gst_schema, host='localhost', port=9000) + ts_gen = TypeScriptGenerator(gst_schema, host="localhost", port=9000) return ts_gen.generate() @@ -59,19 +60,19 @@ def gst_typescript(gst_schema): def gst_girest(): """ Create a GIRest instance for Gst namespace. - + Returns: GIRest: GIRest instance for Gst 1.0 """ - return GIRest('Gst', '1.0') + return GIRest("Gst", "1.0") @pytest.fixture def gobject_girest(): """ Create a GIRest instance for GObject namespace. - + Returns: GIRest: GIRest instance for GObject 2.0 """ - return GIRest('GObject', '2.0') + return GIRest("GObject", "2.0") diff --git a/girest/tests/e2e/conftest.py b/girest/tests/e2e/conftest.py index cb5faa6..f5ed7d8 100644 --- a/girest/tests/e2e/conftest.py +++ b/girest/tests/e2e/conftest.py @@ -8,16 +8,17 @@ - Mock callback server (for non-SSE tests) """ -import sys +import asyncio import os -import pytest +import signal import subprocess +import sys +import tempfile +import threading import time -import signal + import httpx -import asyncio -import threading -import tempfile +import pytest from aiohttp import web # Add parent directory to path for imports @@ -28,15 +29,16 @@ # Helper Functions # ============================================================================ + def get_active_correlation_id(): """ Get the active correlation ID for the current thread. This is used to automatically propagate correlation IDs in reentrant API calls. - + Returns: str or None: The correlation ID if in callback context, None otherwise """ - if not hasattr(threading, '_girest_correlation_id'): + if not hasattr(threading, "_girest_correlation_id"): return None thread_id = threading.get_ident() return threading._girest_correlation_id.get(thread_id) @@ -45,15 +47,15 @@ def get_active_correlation_id(): def set_active_correlation_id(correlation_id): """ Set the active correlation ID for the current thread. - + Args: correlation_id: The correlation ID to set, or None to clear """ - if not hasattr(threading, '_girest_correlation_id'): + if not hasattr(threading, "_girest_correlation_id"): threading._girest_correlation_id = {} - + thread_id = threading.get_ident() - + if correlation_id is None: # Clear correlation ID if thread_id in threading._girest_correlation_id: @@ -66,52 +68,52 @@ def set_active_correlation_id(correlation_id): def inject_correlation_id_header(headers=None): """ Inject X-Correlation-Id header if we're in a callback context. - + Args: headers: Existing headers dict (optional) - + Returns: dict: Headers with correlation ID added if applicable """ if headers is None: headers = {} - + correlation_id = get_active_correlation_id() if correlation_id is not None: - headers['X-Correlation-Id'] = str(correlation_id) - + headers["X-Correlation-Id"] = str(correlation_id) + return headers + def assert_api_success(response, msg="API call failed"): """ Assert that API call succeeded with 2xx status code. - + Args: response: httpx.Response object msg: Optional error message - + Returns: dict: Parsed JSON response - + Raises: AssertionError: If status code is not 2xx """ - assert 200 <= response.status_code < 300, \ - f"{msg}: {response.status_code}, response: {response.text}" + assert 200 <= response.status_code < 300, f"{msg}: {response.status_code}, response: {response.text}" return response.json() def assert_has_ptr(obj, msg="Object should have ptr"): """ Assert that object has a valid pointer field. - + Args: obj: Dictionary that should contain a 'ptr' field msg: Optional error message - + Returns: str: The pointer value - + Raises: AssertionError: If object doesn't have valid ptr """ @@ -124,43 +126,43 @@ def assert_has_ptr(obj, msg="Object should have ptr"): def assert_callback_invocation(callback_data, expected_args=None): """ Assert that callback data has the correct structure for non-SSE callback invocations. - + All non-SSE callbacks follow the same structure: - sessionId: Session identifier - callbackName: Name of the callback parameter - args: Dictionary containing the actual callback arguments (by parameter name) - invocationNumber: Sequential counter - timestamp: ISO 8601 timestamp - + Args: callback_data: The callback data received from the server expected_args: Optional list of expected argument names to verify - + Returns: dict: The args dictionary (the actual callback parameters) - + Raises: AssertionError: If callback structure is invalid """ assert callback_data is not None, "Callback data is None" assert isinstance(callback_data, dict), f"Callback data should be dict, got {type(callback_data)}" - + # Verify metadata fields assert "sessionId" in callback_data, f"Missing 'sessionId' in callback: {callback_data}" assert "callbackName" in callback_data, f"Missing 'callbackName' in callback: {callback_data}" assert "args" in callback_data, f"Missing 'args' in callback: {callback_data}" assert "invocationNumber" in callback_data, f"Missing 'invocationNumber' in callback: {callback_data}" assert "timestamp" in callback_data, f"Missing 'timestamp' in callback: {callback_data}" - + # Verify args structure (now a dict, not a list) args = callback_data["args"] assert isinstance(args, dict), f"args should be a dict, got {type(args)}" - + # Optionally verify expected argument names if expected_args: for arg_name in expected_args: assert arg_name in args, f"Missing '{arg_name}' in callback args: {args}" - + return args @@ -168,58 +170,49 @@ def assert_callback_invocation(callback_data, expected_args=None): # Process Management Fixtures (Session-scoped) # ============================================================================ + @pytest.fixture(scope="session") def gst_pipeline(): """ Start a GStreamer pipeline for E2E testing (session-scoped). - + Launches 'gst-launch-1.0 fakesrc ! fakesink' as a background process. The pipeline is shared across all E2E tests to avoid repeated startup/teardown overhead. - + The pipeline runs continuously with fakesrc producing buffers, which generates bus messages that can be used for callback testing. - + Yields: int: Process ID of the running pipeline """ process = subprocess.Popen( - [ - "gst-launch-1.0", - "fakesrc", - "is-live=true", - "do-timestamp=true", - "!", - "fakesink", - "sync=true" - ], + ["gst-launch-1.0", "fakesrc", "is-live=true", "do-timestamp=true", "!", "fakesink", "sync=true"], stdout=subprocess.PIPE, - stderr=subprocess.PIPE + stderr=subprocess.PIPE, ) - + # Give the pipeline time to start and enter PLAYING state time.sleep(3) - + # Verify it's running if process.poll() is not None: stdout, stderr = process.communicate() raise RuntimeError( - f"GStreamer pipeline failed to start.\n" - f"stdout: {stdout.decode()}\n" - f"stderr: {stderr.decode()}" + f"GStreamer pipeline failed to start.\n" f"stdout: {stdout.decode()}\n" f"stderr: {stderr.decode()}" ) - + print(f"\n✓ GStreamer pipeline started (PID: {process.pid})") - + yield process.pid - + # Cleanup: terminate the pipeline print(f"\n✓ Terminating GStreamer pipeline (PID: {process.pid})") try: process.send_signal(signal.SIGTERM) process.wait(timeout=5) except subprocess.TimeoutExpired: - print(f"⚠ Pipeline didn't terminate gracefully, killing it") + print("⚠ Pipeline didn't terminate gracefully, killing it") process.kill() process.wait() @@ -228,13 +221,13 @@ def gst_pipeline(): def girest_server(gst_pipeline): """ Start the GIRest server in non-SSE mode (session-scoped). - + Launches girest-frida.py attached to the GStreamer pipeline via Frida. This server is used for all basic tests and non-SSE callback tests. - + Args: gst_pipeline: PID of the running GStreamer pipeline - + Yields: str: Base URL of the running server (http://localhost:9000) """ @@ -245,13 +238,13 @@ def girest_server(gst_pipeline): def girest_server_sse(gst_pipeline): """ Start the GIRest server in SSE-only mode (session-scoped). - + Launches girest-frida.py with --sse-only flag. This server is only used for SSE callback tests. - + Args: gst_pipeline: PID of the running GStreamer pipeline - + Yields: str: Base URL of the running server (http://localhost:9001) """ @@ -261,57 +254,49 @@ def girest_server_sse(gst_pipeline): def _start_girest_server(gst_pipeline, sse_only=False, port=9000): """ Internal helper to start GIRest server with specified configuration. - + Args: gst_pipeline: PID of the running GStreamer pipeline sse_only: Whether to enable SSE-only mode port: Port number for the server - + Yields: str: Base URL of the running server """ - + # Verify pipeline is still running try: os.kill(gst_pipeline, 0) except OSError: raise RuntimeError(f"Pipeline process {gst_pipeline} is not running") - + # Get path to girest-frida.py - girest_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), - "girest-frida.py" - ) - + girest_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "girest-frida.py") + # Build command with sse_only flag if needed - cmd = ["python3", "-u", girest_path, "Gst", "1.0", - "--pid", str(gst_pipeline), "--port", str(port)] + cmd = ["python3", "-u", girest_path, "Gst", "1.0", "--pid", str(gst_pipeline), "--port", str(port)] if sse_only: cmd.append("--sse-only") - + # Create log file for server output - log_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.log', prefix='girest-server-') + log_file = tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".log", prefix="girest-server-") log_path = log_file.name print(f"✓ Server logs will be written to: {log_path}") - + # Start server with unbuffered output process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=1, - universal_newlines=True + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, universal_newlines=True ) - + # Wait for server to be ready by monitoring stdout for the "Uvicorn running" message mode = "SSE-only" if sse_only else "non-SSE" print(f"\n✓ Starting GIRest server in {mode} mode (attaching to PID {gst_pipeline})...") - + ready = False startup_output = [] timeout = 60 # Maximum time to wait for server startup start_time = time.time() - + while time.time() - start_time < timeout: # Check if process is still running if process.poll() is not None: @@ -320,11 +305,8 @@ def _start_girest_server(gst_pipeline, sse_only=False, port=9000): startup_output.append(remaining) log_file.write(remaining) log_file.close() - raise RuntimeError( - f"GIRest server process died during startup.\n" - f"output:\n{''.join(startup_output)}" - ) - + raise RuntimeError(f"GIRest server process died during startup.\n" f"output:\n{''.join(startup_output)}") + # Read one line from stdout line = process.stdout.readline() if line: @@ -334,9 +316,9 @@ def _start_girest_server(gst_pipeline, sse_only=False, port=9000): # Check for the ready message if "Uvicorn running on" in line: ready = True - print(f"✓ GIRest server ready (detected startup message)") + print("✓ GIRest server ready (detected startup message)") break - + if not ready: process.send_signal(signal.SIGTERM) try: @@ -345,12 +327,11 @@ def _start_girest_server(gst_pipeline, sse_only=False, port=9000): process.kill() process.wait() raise RuntimeError( - f"GIRest server did not start within {timeout} seconds.\n" - f"output:\n{''.join(startup_output)}" + f"GIRest server did not start within {timeout} seconds.\n" f"output:\n{''.join(startup_output)}" ) - + base_url = f"http://localhost:{port}" - + # Start a background thread to capture server output during test def capture_output(): try: @@ -359,25 +340,25 @@ def capture_output(): log_file.flush() except: pass - + output_thread = threading.Thread(target=capture_output, daemon=True) output_thread.start() - + yield base_url - + # Cleanup: terminate the server - print(f"\n✓ Terminating GIRest server...") + print("\n✓ Terminating GIRest server...") try: process.send_signal(signal.SIGTERM) process.wait(timeout=5) except subprocess.TimeoutExpired: - print(f"⚠ Server didn't terminate gracefully, killing it") + print("⚠ Server didn't terminate gracefully, killing it") process.kill() process.wait() - + # Wait for output thread to finish reading remaining output (with timeout) output_thread.join(timeout=2.0) - + # Close log file log_file.close() print(f"✓ Server logs saved to: {log_path}") @@ -387,17 +368,18 @@ def capture_output(): # Callback Server Fixture (Function-scoped for test isolation) # ============================================================================ + @pytest.fixture async def callback_server(): """ Create a mock HTTP callback server for non-SSE callback tests. - + This server receives callback POSTs from girest and records them for test validation. Each test gets a fresh server instance with clean state. - + The server listens on localhost:8888 and accepts POSTs to any path. - + Yields: CallbackServerHelper: Helper object with methods: - url: str - Base URL of the callback server @@ -409,51 +391,51 @@ async def callback_server(): - clear() - Clear received callbacks list """ received_callbacks = {} # Dict[str, list] - callback_id -> list of received data - callback_events = {} # Dict[str, asyncio.Event] - callback_id -> event - callback_handlers = {} # Dict[str, callable] - callback_id -> custom handler function - + callback_events = {} # Dict[str, asyncio.Event] - callback_id -> event + callback_handlers = {} # Dict[str, callable] - callback_id -> custom handler function + class CallbackServerHelper: def __init__(self, host, port): self.host = host self.port = port self.url = f"http://{host}:{port}" self.runner = None - + def callback_url(self, callback_id="default"): """Get full callback URL for a specific callback identifier.""" return f"{self.url}/callback/{callback_id}" - + async def callback_handler(self, request): """Handle incoming callback POST requests.""" # Extract callback_id from path (e.g., /callback/foreach_pad_test) - path_parts = request.path.strip('/').split('/') + path_parts = request.path.strip("/").split("/") callback_id = path_parts[-1] if len(path_parts) >= 2 else "default" - + # Parse callback data data = await request.json() - + # Extract correlation ID from callback data (sent by server for thread affinity) - correlation_id = data.get('correlationId') - + correlation_id = data.get("correlationId") + # Store the callback data if callback_id not in received_callbacks: received_callbacks[callback_id] = [] received_callbacks[callback_id].append(data) - + # Signal that this specific callback was received if callback_id in callback_events: callback_events[callback_id].set() - + # Check if there's a custom handler configured response_value = None if callback_id in callback_handlers: handler = callback_handlers[callback_id] - + # Call the custom handler in a thread pool to avoid blocking the event loop # This allows the HTTP server to handle reentrant requests from within callbacks # We need to set the correlation ID in the thread pool thread, not here import asyncio - + def handler_with_correlation_id(): # Set correlation ID for automatic propagation in reentrant calls try: @@ -462,74 +444,74 @@ def handler_with_correlation_id(): finally: # Clean up correlation ID set_active_correlation_id(None) - + response_value = await asyncio.to_thread(handler_with_correlation_id) else: # Default response for sync callbacks: return True to continue iteration response_value = True - + # Return response matching the OpenAPI schema: {CallbackName}Return format # The schema has a "return" property for the return value return web.json_response({"return": response_value}) - + async def start(self): """Start the callback server.""" app = web.Application() # Add route that matches any callback path - app.router.add_post('/callback/{callback_id}', self.callback_handler) - + app.router.add_post("/callback/{callback_id}", self.callback_handler) + self.runner = web.AppRunner(app) await self.runner.setup() site = web.TCPSite(self.runner, self.host, self.port) await site.start() print(f"✓ Callback server started at {self.url}") - + async def stop(self): """Stop the callback server.""" if self.runner: await self.runner.cleanup() - print(f"✓ Callback server stopped") - + print("✓ Callback server stopped") + def set_callback_handler(self, callback_id, handler): """ Set a custom handler function for a specific callback. - + The handler will be called with the callback data and should return the value to send back in the HTTP response (for sync callbacks). - + Args: callback_id: Identifier for the callback handler: Callable that takes callback_data and returns a response value - + Example: def my_handler(callback_data): args = callback_data["args"][0] print(f"Received pad: {args['pad']}") return True # Continue iteration - + callback_server.set_callback_handler("test_id", my_handler) """ callback_handlers[callback_id] = handler - + async def wait_for_callback(self, callback_id="default", timeout=5.0): """ Wait for a specific callback to be received and return its data. - + Args: callback_id: Identifier for the callback to wait for timeout: Maximum time to wait in seconds - + Returns: dict: The callback data that was received, or None if timeout """ # Check if callback has already arrived if callback_id in received_callbacks and received_callbacks[callback_id]: return received_callbacks[callback_id][0] - + # Create event if it doesn't exist if callback_id not in callback_events: callback_events[callback_id] = asyncio.Event() - + try: await asyncio.wait_for(callback_events[callback_id].wait(), timeout=timeout) # Return the first received callback for this ID @@ -537,91 +519,66 @@ async def wait_for_callback(self, callback_id="default", timeout=5.0): return callbacks[0] if callbacks else None except asyncio.TimeoutError: return None - + async def wait_for_callbacks(self, callback_id="default", expected_count=1, timeout=5.0): """ Wait for a specific number of callbacks to be received. - + Useful for async callbacks that may arrive after the API call completes. For sync callbacks, this is usually not needed - just check get_callbacks() after the call. - - Args: - callback_id: Identifier for the callback to wait for - expected_count: Number of callbacks expected - timeout: Maximum time to wait in seconds - - Returns: - list: List of callback data received, or None if timeout/count mismatch - """ - start_time = asyncio.get_event_loop().time() - - while asyncio.get_event_loop().time() - start_time < timeout: - callbacks = received_callbacks.get(callback_id, []) - if len(callbacks) >= expected_count: - return callbacks[:expected_count] - - # Wait a bit before checking again - await asyncio.sleep(0.1) - - # Timeout - return None - return None - - async def wait_for_callbacks(self, callback_id="default", expected_count=1, timeout=5.0): - """ - Wait for a specific number of callbacks to be received. - + Args: callback_id: Identifier for the callback to wait for expected_count: Number of callbacks expected timeout: Maximum time to wait in seconds - + Returns: list: List of callback data received, or None if timeout/count mismatch """ start_time = asyncio.get_event_loop().time() - + while asyncio.get_event_loop().time() - start_time < timeout: callbacks = received_callbacks.get(callback_id, []) if len(callbacks) >= expected_count: return callbacks[:expected_count] - + # Wait a bit before checking again await asyncio.sleep(0.1) - + # Timeout - return None return None - + def get_callbacks(self, callback_id=None): """ Get received callbacks. - + Args: callback_id: If provided, get only callbacks for this ID - + Returns: list or dict: List of callbacks for specific ID, or all callbacks dict """ if callback_id: return received_callbacks.get(callback_id, []) return dict(received_callbacks) - + def get_last_callback(self, callback_id="default"): """Get the most recently received callback for a specific ID, or None.""" callbacks = received_callbacks.get(callback_id, []) return callbacks[-1] if callbacks else None - + def clear(self): """Clear all received callbacks, events, and handlers.""" received_callbacks.clear() callback_events.clear() callback_handlers.clear() - + # Create and start the helper - helper = CallbackServerHelper('localhost', 8888) + helper = CallbackServerHelper("localhost", 8888) await helper.start() - + yield helper - + # Cleanup await helper.stop() @@ -630,29 +587,27 @@ def clear(self): # GStreamer Element Factory Fixtures # ============================================================================ + @pytest.fixture async def gst_bin_factory(girest_server): """ Factory fixture for creating GstBin elements. - + Usage: bin_ptr = await gst_bin_factory("my_bin") - + Automatically unrefs all created bins on teardown. """ created_bins = [] - + async def create_bin(name=None): """Create a GstBin element and track it for cleanup.""" async with httpx.AsyncClient(timeout=30.0) as client: params = {"factoryname": "bin"} if name: params["name"] = name - - response = await client.get( - f"{girest_server}/Gst/ElementFactory/make", - params=params - ) + + response = await client.get(f"{girest_server}/Gst/ElementFactory/make", params=params) assert_api_success(response, f"Failed to create bin '{name}'") bin_data = response.json() assert "return" in bin_data @@ -660,9 +615,9 @@ async def create_bin(name=None): bin_ptr = bin_data["return"]["ptr"] created_bins.append(bin_ptr) return bin_ptr - + yield create_bin - + # Cleanup: unref all created bins async with httpx.AsyncClient(timeout=30.0) as client: for bin_ptr in created_bins: @@ -676,25 +631,22 @@ async def create_bin(name=None): async def gst_identity_factory(girest_server): """ Factory fixture for creating identity elements. - + Usage: identity_ptr = await gst_identity_factory("my_identity") - + Automatically unrefs all created identities on teardown. """ created_identities = [] - + async def create_identity(name=None): """Create an identity element and track it for cleanup.""" async with httpx.AsyncClient(timeout=30.0) as client: params = {"factoryname": "identity"} if name: params["name"] = name - - response = await client.get( - f"{girest_server}/Gst/ElementFactory/make", - params=params - ) + + response = await client.get(f"{girest_server}/Gst/ElementFactory/make", params=params) assert_api_success(response, f"Failed to create identity '{name}'") identity_data = response.json() assert "return" in identity_data @@ -702,9 +654,9 @@ async def create_identity(name=None): identity_ptr = identity_data["return"]["ptr"] created_identities.append(identity_ptr) return identity_ptr - + yield create_identity - + # Cleanup: unref all created identities async with httpx.AsyncClient(timeout=30.0) as client: for identity_ptr in created_identities: diff --git a/girest/tests/e2e/test_e2e_basic.py b/girest/tests/e2e/test_e2e_basic.py index 3026470..982ae19 100644 --- a/girest/tests/e2e/test_e2e_basic.py +++ b/girest/tests/e2e/test_e2e_basic.py @@ -13,34 +13,33 @@ and are session-scoped, meaning they're shared across all E2E tests. """ -import pytest import httpx -import asyncio +import pytest # Import helper functions from conftest -from conftest import assert_api_success, assert_has_ptr +from conftest import assert_api_success @pytest.mark.asyncio async def test_string_ret_endpoint(girest_server): """ Test the /Gst/version_string endpoint which returns a string. - + This tests that non-void return values are properly returned in the HTTP response. The version_string endpoint should return the GStreamer version as a string. """ async with httpx.AsyncClient() as client: response = await client.get(f"{girest_server}/Gst/version_string") - assert_api_success(response, f"Failed to get version string") - + assert_api_success(response, "Failed to get version string") + # Check the response is JSON data = response.json() - + # Check that the response contains a 'return' field with a string value assert "return" in data, "Response should contain 'return' field" assert isinstance(data["return"], str), "Return value should be a string" assert len(data["return"]) > 0, "Version string should not be empty" - + # Version string should contain numbers and dots assert any(c.isdigit() for c in data["return"]), "Version should contain digits" @@ -49,30 +48,30 @@ async def test_string_ret_endpoint(girest_server): async def test_basic_out_endpoint(girest_server): """ Test the /Gst/version endpoint which returns output integer parameters. - + This tests that output parameters are properly returned in the HTTP response. The version endpoint should return major, minor, micro, and nano version numbers. """ async with httpx.AsyncClient() as client: response = await client.get(f"{girest_server}/Gst/version") - assert_api_success(response, f"Failed to get version") - + assert_api_success(response, "Failed to get version") + # Check the response is JSON data = response.json() - + # Check that the response contains the output parameters # Based on GStreamer documentation, version returns major, minor, micro, and nano assert "major" in data, "Response should contain 'major' field" assert "minor" in data, "Response should contain 'minor' field" assert "micro" in data, "Response should contain 'micro' field" assert "nano" in data, "Response should contain 'nano' field" - + # Check that all values are integers assert isinstance(data["major"], int), "major should be an integer" assert isinstance(data["minor"], int), "minor should be an integer" assert isinstance(data["micro"], int), "micro should be an integer" assert isinstance(data["nano"], int), "nano should be an integer" - + # Sanity check: major version should be reasonable (GStreamer 1.x or later) assert data["major"] >= 1, f"Unexpected major version: {data['major']}" @@ -81,11 +80,11 @@ async def test_basic_out_endpoint(girest_server): async def test_gtype_out_endpoint(girest_server): """ Test struct out parameter handling with GstIterator::next and GValue. - + This tests the case where a struct (GValue) is used as an out parameter in a method call (GstIterator::next). The GValue has a registered GType, so it should be typed as "gtype" and properly dereferenced. - + The test follows the complete flow: 1. Create a GstBin 2. Add a GstElement to the bin (so iterator has something to return) @@ -97,50 +96,56 @@ async def test_gtype_out_endpoint(girest_server): async with httpx.AsyncClient(timeout=10.0) as client: # Step 1: Create a GstBin response = await client.get(f"{girest_server}/Gst/Bin/new", params={"name": "test_bin"}) - assert_api_success(response, f"Failed to create bin") + assert_api_success(response, "Failed to create bin") response_data = response.json() assert "return" in response_data, "Bin creation should return an object" assert "ptr" in response_data["return"], "Bin creation should return an object" bin_ptr = response_data["return"]["ptr"] response = await client.get(f"{girest_server}/Gst/Object/ptr,{bin_ptr}/get_name") - assert_api_success(response, f"Failed to get bin's name") - + assert_api_success(response, "Failed to get bin's name") + # Step 2: Create a GstElement to add to the bin - response = await client.get(f"{girest_server}/Gst/ElementFactory/make", params={"factoryname": "fakesrc", "name": "test_element"}) - assert_api_success(response, f"Failed to create element") + response = await client.get( + f"{girest_server}/Gst/ElementFactory/make", params={"factoryname": "fakesrc", "name": "test_element"} + ) + assert_api_success(response, "Failed to create element") response_data = response.json() assert "return" in response_data, "Element creation should return an object" assert "ptr" in response_data["return"], "Element creation should return an object" element_ptr = response_data["return"]["ptr"] - + # Step 3: Add the element to the bin # Note: Objects are serialized as "ptr,value" per OpenAPI spec (style=form, explode=false for query params) - response = await client.get(f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", params={"element": f"ptr,{element_ptr}"}) - assert_api_success(response, f"Failed to add element into the bin") - + response = await client.get( + f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", params={"element": f"ptr,{element_ptr}"} + ) + assert_api_success(response, "Failed to add element into the bin") + # Step 4: Get an iterator for the bin's elements response = await client.get(f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/iterate_elements") - assert_api_success(response, f"Failed to iterate elements") + assert_api_success(response, "Failed to iterate elements") response_data = response.json() assert "return" in response_data, "Iterate elements should return an object" assert "ptr" in response_data["return"], "Iterate elements should return an object" iterator_ptr = response_data["return"]["ptr"] - + # Step 5: Test GValue creation response = await client.get(f"{girest_server}/GObject/Value/new") - assert_api_success(response, f"Failed to create a value") + assert_api_success(response, "Failed to create a value") response_data = response.json() assert "return" in response_data, "Value new should return an object" assert "ptr" in response_data["return"], "Value new should return an object" value_ptr = response_data["return"]["ptr"] - + # Step 6: Unset the GValue response = await client.get(f"{girest_server}/GObject/Value/ptr,{value_ptr}/unset") - assert_api_success(response, f"Failed to unset the value") + assert_api_success(response, "Failed to unset the value") # Step 7: Try to call iterator next - response = await client.get(f"{girest_server}/Gst/Iterator/ptr,{iterator_ptr}/next", params={"elem": f"ptr,{value_ptr}"}) - assert_api_success(response, f"Failed to iterate next") + response = await client.get( + f"{girest_server}/Gst/Iterator/ptr,{iterator_ptr}/next", params={"elem": f"ptr,{value_ptr}"} + ) + assert_api_success(response, "Failed to iterate next") response_data = response.json() # The result should contain the return value and may contain the out parameter 'elem' assert "return" in response_data @@ -151,7 +156,7 @@ async def test_gtype_out_endpoint(girest_server): async def test_gst_bin_get_type_endpoint(girest_server): """ Test the /Gst/Bin/get_type endpoint which returns the GType for GstBin. - + This tests that get_type static methods work correctly and return a valid GType. The get_type endpoint should return a pointer value representing the GType for GstBin. GTypes are fundamental identifiers in GObject that represent registered types. @@ -159,17 +164,17 @@ async def test_gst_bin_get_type_endpoint(girest_server): async with httpx.AsyncClient() as client: response = await client.get(f"{girest_server}/Gst/Bin/get_type") assert_api_success(response, "Failed to get GstBin GType") - + # Check the response is JSON data = response.json() - + # Check that the response contains a 'return' field with a numeric value assert "return" in data, "Response should contain 'return' field" - + # GType should be a numeric value (represented as integer or hex string) gtype_value = data["return"] assert gtype_value is not None, "GType should not be null" - + # GType can be returned as integer or as a string representing a pointer # Validate that it's a reasonable value (positive number or valid hex string) if isinstance(gtype_value, int): @@ -192,92 +197,95 @@ async def test_gst_bin_get_type_endpoint(girest_server): assert False, f"Invalid decimal GType value: {gtype_value}" else: assert False, f"GType should be integer or string, got type {type(gtype_value)}: {gtype_value}" - + print(f"✓ Successfully tested /Gst/Bin/get_type endpoint - returned GType: {gtype_value}") - + # Additional validation: ensure the GType is consistent across calls response2 = await client.get(f"{girest_server}/Gst/Bin/get_type") assert_api_success(response2, "Failed to get GstBin GType on second call") data2 = response2.json() - - assert data2["return"] == gtype_value, f"GType should be consistent across calls: {gtype_value} != {data2['return']}" - print(f"✓ GType consistency verified across multiple calls") + + assert ( + data2["return"] == gtype_value + ), f"GType should be consistent across calls: {gtype_value} != {data2['return']}" + print("✓ GType consistency verified across multiple calls") @pytest.mark.asyncio async def test_enum_returned_as_string(girest_server): """ Test that enum values are returned as strings instead of integers. - + This test validates that GStreamer enum types (like GstStateChangeReturn) - are properly serialized as their string representations rather than + are properly serialized as their string representations rather than their underlying integer values. This is critical for API usability as string enums are more readable and type-safe. - - The test uses /Gst/Element/state_change_return_get_name which accepts a + + The test uses /Gst/Element/state_change_return_get_name which accepts a GstStateChangeReturn enum string and returns the string name, validating both that enum inputs accept strings and that the API properly handles them. """ async with httpx.AsyncClient(timeout=10.0) as client: # Test all valid GstStateChangeReturn enum values from the schema valid_state_change_returns = ["failure", "success", "async", "no_preroll"] - + for enum_value in valid_state_change_returns: # Call the endpoint with the enum string value response = await client.get( - f"{girest_server}/Gst/Element/state_change_return_get_name", - params={"state_ret": enum_value} + f"{girest_server}/Gst/Element/state_change_return_get_name", params={"state_ret": enum_value} ) assert_api_success(response, f"Failed to get name for state_ret='{enum_value}'") response_data = response.json() - + # Validate the response structure assert "return" in response_data, f"Response should contain 'return' field for enum '{enum_value}'" name_result = response_data["return"] - + # The return value should be a string (the human-readable name) - assert isinstance(name_result, str), \ - f"state_change_return_get_name should return string, got {type(name_result)}: {name_result}" - + assert isinstance( + name_result, str + ), f"state_change_return_get_name should return string, got {type(name_result)}: {name_result}" + # The returned string should not be empty assert len(name_result) > 0, f"Returned name should not be empty for enum '{enum_value}'" - + # The name should be different from the input (it's the human-readable form) # and typically contains uppercase/spaces (e.g., "GST_STATE_CHANGE_SUCCESS") - assert name_result != enum_value, \ - f"Returned name '{name_result}' should be different from input enum '{enum_value}'" - + assert ( + name_result != enum_value + ), f"Returned name '{name_result}' should be different from input enum '{enum_value}'" + print(f"✓ Enum '{enum_value}' -> Name '{name_result}' (validated string)") - + # Test that invalid enum values are properly rejected invalid_enum_value = "invalid_enum_value" response = await client.get( - f"{girest_server}/Gst/Element/state_change_return_get_name", - params={"state_ret": invalid_enum_value} + f"{girest_server}/Gst/Element/state_change_return_get_name", params={"state_ret": invalid_enum_value} ) - + # This should result in an error (4xx status code) because the enum value is invalid - assert response.status_code >= 400, \ - f"Invalid enum value '{invalid_enum_value}' should be rejected with 4xx status, got {response.status_code}" - + assert ( + response.status_code >= 400 + ), f"Invalid enum value '{invalid_enum_value}' should be rejected with 4xx status, got {response.status_code}" + print(f"✓ Invalid enum value '{invalid_enum_value}' properly rejected with status {response.status_code}") - print(f"✓ Successfully validated that all enum values are handled as strings:") - print(f" - All valid GstStateChangeReturn enum strings were accepted as input") - print(f" - All responses contained string return values (not integers)") - print(f" - Invalid enum strings were properly rejected") - print(f"✓ Enum serialization working correctly - strings instead of integers") + print("✓ Successfully validated that all enum values are handled as strings:") + print(" - All valid GstStateChangeReturn enum strings were accepted as input") + print(" - All responses contained string return values (not integers)") + print(" - Invalid enum strings were properly rejected") + print("✓ Enum serialization working correctly - strings instead of integers") @pytest.mark.asyncio async def test_glist_field_iteration(girest_server): """ Test field access on GList by iterating through the 'next' field. - + This test validates the field access functionality by: 1. Getting a GstRegistry using /Gst/Registry/get 2. Getting a GList of plugins using /Gst/Registry/{self}/get_plugins_list 3. Iterating through the GList by accessing the 'next' field until it's null - + This tests that: - Field GET endpoints work correctly (reading struct fields) - Pointer fields are properly serialized and deserialized @@ -292,48 +300,48 @@ async def test_glist_field_iteration(girest_server): assert "ptr" in response_data["return"], "Registry should have a ptr field" registry_ptr = response_data["return"]["ptr"] print(f"✓ Got GstRegistry at {registry_ptr}") - + # Step 2: Get the plugin list from the registry response = await client.get(f"{girest_server}/Gst/Registry/ptr,{registry_ptr}/get_plugin_list") assert_api_success(response, "Failed to get plugin list from registry") response_data = response.json() assert "return" in response_data, "get_plugin_list should return an object" - + # The return value should be a GList pointer glist = response_data["return"] if glist is None: print("⚠ Plugin list is empty (no plugins registered)") return - + assert "ptr" in glist, "GList should have a ptr field" current_ptr = glist["ptr"] print(f"✓ Got GList starting at {current_ptr}") - + # Step 3: Iterate through the GList using the 'next' field iteration_count = 0 max_iterations = 100 # Safety limit to prevent infinite loops - + while current_ptr and current_ptr != "0x0" and current_ptr != 0 and iteration_count < max_iterations: iteration_count += 1 print(f" Iteration {iteration_count}: GList node at {current_ptr}") - + # Access the 'next' field of the current GList node # The operation ID format is: GLib-List-next-get response = await client.get(f"{girest_server}/GLib/List/ptr,{current_ptr}/fields/next") assert_api_success(response, f"Failed to get 'next' field from GList at iteration {iteration_count}") response_data = response.json() - + # The response should contain a 'return' field assert "return" in response_data, f"Field access should return a value at iteration {iteration_count}" - + # The 'next' field is a pointer to the next GList node (or null) next_value = response_data["return"] - + if next_value is None or (isinstance(next_value, dict) and next_value.get("ptr") in ["0x0", 0, None]): # Reached the end of the list print(f"✓ Reached end of list at iteration {iteration_count}") break - + # The next field should be a struct/pointer with a ptr field if isinstance(next_value, dict) and "ptr" in next_value: current_ptr = next_value["ptr"] @@ -346,20 +354,20 @@ async def test_glist_field_iteration(girest_server): if current_ptr in ["0x0", 0, None]: print(f"✓ Reached end of list (null pointer) at iteration {iteration_count}") break - + # Validate that we iterated through at least some nodes # GStreamer usually has many plugins registered, so we should see multiple nodes assert iteration_count > 0, "Should have iterated through at least one GList node" print(f"✓ Successfully iterated through {iteration_count} GList nodes using field access") - + # If we hit the max_iterations limit, that's okay - it just means there are many plugins # We've proven field access works, which was the goal if iteration_count >= max_iterations: print(f"✓ Reached iteration limit of {max_iterations} - field access is working correctly") - - print(f"✓ Field access test completed successfully:") - print(f" - Retrieved GstRegistry singleton") - print(f" - Got Plugins list (GList)") + + print("✓ Field access test completed successfully:") + print(" - Retrieved GstRegistry singleton") + print(" - Got Plugins list (GList)") print(f" - Iterated through {iteration_count} nodes using 'next' field") - print(f" - Properly detected end of list (null pointer)") - print(f"✓ GList field iteration test passed!") + print(" - Properly detected end of list (null pointer)") + print("✓ GList field iteration test passed!") diff --git a/girest/tests/e2e/test_e2e_callbacks_nonsse.py b/girest/tests/e2e/test_e2e_callbacks_nonsse.py index 9f973af..dbcdc9a 100644 --- a/girest/tests/e2e/test_e2e_callbacks_nonsse.py +++ b/girest/tests/e2e/test_e2e_callbacks_nonsse.py @@ -16,63 +16,57 @@ - For async callbacks: fire-and-forget """ -import pytest -import httpx import asyncio -import json -from conftest import assert_api_success, assert_has_ptr, assert_callback_invocation + +import httpx +import pytest +from conftest import assert_api_success, assert_callback_invocation, assert_has_ptr @pytest.mark.asyncio async def test_call_scope_continues_on_true(girest_server, callback_server, gst_identity_factory): """ Test that call-scope callbacks continue iteration when returning True. - + This test uses an identity element which has 2 pads (sink and src). When the callback returns True, both pads should be visited. """ async with httpx.AsyncClient(timeout=30.0) as client: # Step 1: Create an identity element (has sink and src pads) identity_ptr = await gst_identity_factory("test_identity") - + # Step 2: Set up callback handler that returns True (continue iteration) received_pads = [] - + def my_callback_handler(callback_data): """Custom handler that tracks pads and returns True to continue.""" args = assert_callback_invocation(callback_data, expected_args=["element", "pad", "user_data"]) received_pads.append(args["pad"]["ptr"]) return True # Continue iteration - + callback_server.set_callback_handler("foreach_pad_test", my_callback_handler) - + # Step 3: Call foreach_pad with our callback URL callback_url = callback_server.callback_url("foreach_pad_test") - + response = await client.get( f"{girest_server}/Gst/Element/ptr,{identity_ptr}/foreach_pad", params={"func": callback_url}, - headers={ - "session-id": "test-session-123", - "callback-secret": "test-secret-456" - } + headers={"session-id": "test-session-123", "callback-secret": "test-secret-456"}, ) assert_api_success(response, "Failed to call foreach_pad") result_data = response.json() - + # Step 4: Verify we received callbacks for BOTH pads (since we returned True) - assert len(received_pads) == 2, \ - f"Expected 2 pad callbacks (continued iteration), got {len(received_pads)}" - + assert len(received_pads) == 2, f"Expected 2 pad callbacks (continued iteration), got {len(received_pads)}" + # Verify both pads are different - assert received_pads[0] != received_pads[1], \ - f"Expected different pads, got same: {received_pads[0]}" - + assert received_pads[0] != received_pads[1], f"Expected different pads, got same: {received_pads[0]}" + # Verify all callbacks are stored all_callbacks = callback_server.get_callbacks("foreach_pad_test") - assert len(all_callbacks) == 2, \ - f"Expected 2 callbacks stored, got {len(all_callbacks)}" - + assert len(all_callbacks) == 2, f"Expected 2 callbacks stored, got {len(all_callbacks)}" + # Step 5: Verify the method completed successfully assert "return" in result_data assert isinstance(result_data["return"], bool) @@ -82,7 +76,7 @@ def my_callback_handler(callback_data): async def test_call_scope_stops_on_false(girest_server, callback_server, gst_identity_factory): """ Test that call-scope callbacks stop iteration when returning False. - + This test uses an identity element which has 2 pads (sink and src). When the callback returns False on the first invocation, iteration should stop and only 1 pad should be visited. @@ -90,41 +84,36 @@ async def test_call_scope_stops_on_false(girest_server, callback_server, gst_ide async with httpx.AsyncClient(timeout=30.0) as client: # Step 1: Create an identity element (has sink and src pads) identity_ptr = await gst_identity_factory("test_identity") - + # Step 2: Set up callback handler that returns False (stop iteration) received_pads = [] - + def my_callback_handler(callback_data): """Custom handler that tracks pads and returns False to stop iteration.""" args = assert_callback_invocation(callback_data, expected_args=["element", "pad", "user_data"]) received_pads.append(args["pad"]["ptr"]) return False # Stop iteration after first callback - + callback_server.set_callback_handler("foreach_pad_test", my_callback_handler) - + # Step 3: Call foreach_pad with our callback URL callback_url = callback_server.callback_url("foreach_pad_test") - + response = await client.get( f"{girest_server}/Gst/Element/ptr,{identity_ptr}/foreach_pad", params={"func": callback_url}, - headers={ - "session-id": "test-session-123", - "callback-secret": "test-secret-456" - } + headers={"session-id": "test-session-123", "callback-secret": "test-secret-456"}, ) assert_api_success(response, "Failed to call foreach_pad") result_data = response.json() - + # Step 4: Verify we received callback for ONLY ONE pad (since we returned False) - assert len(received_pads) == 1, \ - f"Expected 1 pad callback (stopped iteration), got {len(received_pads)}" - + assert len(received_pads) == 1, f"Expected 1 pad callback (stopped iteration), got {len(received_pads)}" + # Verify only one callback is stored all_callbacks = callback_server.get_callbacks("foreach_pad_test") - assert len(all_callbacks) == 1, \ - f"Expected 1 callback stored, got {len(all_callbacks)}" - + assert len(all_callbacks) == 1, f"Expected 1 callback stored, got {len(all_callbacks)}" + # Step 5: Verify the method completed successfully assert "return" in result_data assert isinstance(result_data["return"], bool) @@ -134,11 +123,11 @@ def my_callback_handler(callback_data): async def test_call_scope_reentrancy(girest_server, callback_server, gst_identity_factory): """ Test that Frida handles reentrancy correctly when call-scope callbacks make API calls. - + This test verifies that when a callback makes synchronous API calls back to the server (ref/unref on the pad), Frida properly handles the reentrant calls without deadlocking or crashing. - + The sequence is: 1. Client calls foreach_pad (blocks waiting for response) 2. Server invokes callback, POSTs to client's callback URL (blocks) @@ -149,11 +138,11 @@ async def test_call_scope_reentrancy(girest_server, callback_server, gst_identit async with httpx.AsyncClient(timeout=30.0) as client: # Step 1: Create an identity element (has sink and src pads) identity_ptr = await gst_identity_factory("test_identity") - + # Step 2: Set up callback handler that makes reentrant API calls received_pads = [] reentrant_calls_succeeded = [] - + def reentrant_callback_handler(callback_data): """Handler that makes reentrant API calls (ref/unref) on the pad.""" args = assert_callback_invocation(callback_data, expected_args=["element", "pad", "user_data"]) @@ -161,61 +150,50 @@ def reentrant_callback_handler(callback_data): assert_has_ptr(pad) pad_ptr = pad["ptr"] received_pads.append(pad_ptr) - + # Make reentrant API calls: ref the pad, then unref it # This tests that Frida can handle API calls while processing a callback try: - ref_response = httpx.get( - f"{girest_server}/Gst/Object/ptr,{pad_ptr}/ref", - timeout=10.0 - ) + ref_response = httpx.get(f"{girest_server}/Gst/Object/ptr,{pad_ptr}/ref", timeout=10.0) assert_api_success(ref_response, "Reentrant ref call failed") ref_data = ref_response.json() assert "return" in ref_data assert_has_ptr(ref_data["return"]) - - unref_response = httpx.get( - f"{girest_server}/Gst/Object/ptr,{pad_ptr}/unref", - timeout=10.0 - ) + + unref_response = httpx.get(f"{girest_server}/Gst/Object/ptr,{pad_ptr}/unref", timeout=10.0) assert_api_success(unref_response, "Reentrant unref call failed") - + reentrant_calls_succeeded.append(True) - except Exception as e: + except Exception: reentrant_calls_succeeded.append(False) - + return True # Continue iteration - + callback_server.set_callback_handler("reentrancy_test", reentrant_callback_handler) - + # Step 3: Call foreach_pad with our callback URL callback_url = callback_server.callback_url("reentrancy_test") - + response = await client.get( f"{girest_server}/Gst/Element/ptr,{identity_ptr}/foreach_pad", params={"func": callback_url}, - headers={ - "session-id": "test-session-123", - "callback-secret": "test-secret-456" - } + headers={"session-id": "test-session-123", "callback-secret": "test-secret-456"}, ) assert_api_success(response, "Failed to call foreach_pad") result_data = response.json() - + # Step 4: Verify we received callbacks for both pads - assert len(received_pads) == 2, \ - f"Expected 2 pad callbacks, got {len(received_pads)}" - + assert len(received_pads) == 2, f"Expected 2 pad callbacks, got {len(received_pads)}" + # Verify both pads are different - assert received_pads[0] != received_pads[1], \ - f"Expected different pads, got same: {received_pads[0]}" - + assert received_pads[0] != received_pads[1], f"Expected different pads, got same: {received_pads[0]}" + # Verify all reentrant calls succeeded - assert len(reentrant_calls_succeeded) == 2, \ - f"Expected 2 reentrant call attempts, got {len(reentrant_calls_succeeded)}" - assert all(reentrant_calls_succeeded), \ - "Some reentrant API calls failed" - + assert ( + len(reentrant_calls_succeeded) == 2 + ), f"Expected 2 reentrant call attempts, got {len(reentrant_calls_succeeded)}" + assert all(reentrant_calls_succeeded), "Some reentrant API calls failed" + # Step 5: Verify the method completed successfully assert "return" in result_data assert isinstance(result_data["return"], bool) @@ -225,58 +203,51 @@ def reentrant_callback_handler(callback_data): # Async Scope Tests (Signals) # ============================================================================ + @pytest.mark.asyncio async def test_async_scope(girest_server, callback_server, gst_bin_factory, gst_identity_factory): """ Test GObject signal connection and disconnection (async-scope callbacks). - + Signals use GI_SCOPE_TYPE_ASYNC, meaning they're invoked AFTER the method that triggers them returns (asynchronous from the caller's perspective). However, the HTTP callback is still synchronous - we wait for it to complete. - + This test verifies: 1. Signals can be connected with a callback URL 2. Signals are triggered when the event occurs (after method returns) 3. Signals can be disconnected 4. Disconnected signals are not triggered - + Uses the 'element-added' signal on GstBin which fires when a child element is added. """ async with httpx.AsyncClient(timeout=30.0) as client: # Step 1: Create a bin bin_ptr = await gst_bin_factory("test_bin") - + # Step 2: Connect to the element-added signal signal_triggered = [] - + def signal_handler(callback_data): """Handler for element-added signal.""" # Signal callbacks include 'self' (the emitter) as first parameter args = assert_callback_invocation(callback_data, expected_args=["self", "element"]) - + # Verify 'self' is the bin that emitted the signal - assert args["self"]["ptr"] == bin_ptr, \ - f"Signal 'self' parameter should be bin {bin_ptr}, got {args['self']['ptr']}" - - signal_triggered.append({ - "self": args["self"]["ptr"], - "element": args["element"]["ptr"] - }) + assert ( + args["self"]["ptr"] == bin_ptr + ), f"Signal 'self' parameter should be bin {bin_ptr}, got {args['self']['ptr']}" + + signal_triggered.append({"self": args["self"]["ptr"], "element": args["element"]["ptr"]}) return None # Signals don't return values - + callback_server.set_callback_handler("element_added_signal", signal_handler) callback_url = callback_server.callback_url("element_added_signal") - + response = await client.post( f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/signals/element-added/connect", - json={ - "flags": "default", - "handler": callback_url - }, - headers={ - "session-id": "test-session-signal", - "callback-secret": "test-secret-signal" - } + json={"flags": "default", "handler": callback_url}, + headers={"session-id": "test-session-signal", "callback-secret": "test-secret-signal"}, ) assert_api_success(response, "Failed to connect to element-added signal") signal_data = response.json() @@ -284,52 +255,46 @@ def signal_handler(callback_data): signal_id = signal_data["return"] assert isinstance(signal_id, int) or isinstance(signal_id, str) assert int(signal_id) > 0, f"Invalid signal ID: {signal_id}" - + # Step 3: Create an identity element identity1_ptr = await gst_identity_factory("identity1") - + # Step 4: Add the identity to the bin response = await client.get( - f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", - params={"element": f"ptr,{identity1_ptr}"} + f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", params={"element": f"ptr,{identity1_ptr}"} ) assert_api_success(response, "Failed to add identity1 to bin") - + # Give signal a moment to trigger await asyncio.sleep(0.5) - + # Step 5: Confirm that the signal was triggered - assert len(signal_triggered) == 1, \ - f"Expected 1 signal trigger, got {len(signal_triggered)}" - assert signal_triggered[0]["element"] == identity1_ptr, \ - "Signal element pointer doesn't match identity1" - + assert len(signal_triggered) == 1, f"Expected 1 signal trigger, got {len(signal_triggered)}" + assert signal_triggered[0]["element"] == identity1_ptr, "Signal element pointer doesn't match identity1" + # Step 6: Remove the signal handler response = await client.get( f"{girest_server}/GObject/signal_handler_disconnect", - params={ - "instance": f"ptr,{bin_ptr}", - "handler_id": signal_id - } + params={"instance": f"ptr,{bin_ptr}", "handler_id": signal_id}, ) assert_api_success(response, "Failed to disconnect signal") - + # Clear the signal tracking signal_triggered.clear() - + # Step 7: Create a new identity identity2_ptr = await gst_identity_factory("identity2") - + # Step 8: Add the identity to the bin response = await client.get( - f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", - params={"element": f"ptr,{identity2_ptr}"} + f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", params={"element": f"ptr,{identity2_ptr}"} ) assert_api_success(response, "Failed to add identity2 to bin") - + # Give signal a moment (if it were to trigger) await asyncio.sleep(0.5) - + # Step 9: Confirm that the signal was NOT triggered again - assert len(signal_triggered) == 0, \ - f"Signal should not trigger after disconnect, but got {len(signal_triggered)} triggers" + assert ( + len(signal_triggered) == 0 + ), f"Signal should not trigger after disconnect, but got {len(signal_triggered)} triggers" diff --git a/girest/tests/e2e/test_e2e_callbacks_sse.py b/girest/tests/e2e/test_e2e_callbacks_sse.py index 783e30f..ac1d68f 100644 --- a/girest/tests/e2e/test_e2e_callbacks_sse.py +++ b/girest/tests/e2e/test_e2e_callbacks_sse.py @@ -12,41 +12,44 @@ and receive real-time updates as events occur. """ -import pytest -import httpx import asyncio import json -from conftest import assert_api_success, assert_has_ptr +import httpx +import pytest +from conftest import assert_api_success + +@pytest.mark.skip(reason="SSE callbacks will be removed in future version") @pytest.mark.asyncio async def test_callbacks_endpoint_with_foreach_pad(girest_server_sse): """ Test the callbacks endpoint by creating a fakesrc element and iterating over its pads. - + This test validates the callbacks functionality by: 1. Creating a fakesrc element 2. Calling the foreach_pad endpoint which should emit callbacks 3. Listening to the /GIRest/callbacks endpoint to receive callback events 4. Validating that callback events are properly emitted and received - + The foreach_pad function will iterate over all pads of the element and call the provided callback function for each pad. Based on the callback schema, each callback should include the element, pad, and user_data parameters. """ async with httpx.AsyncClient(timeout=15.0) as client: # Step 1: Create a fakesrc element - response = await client.get(f"{girest_server_sse}/Gst/ElementFactory/make", - params={"factoryname": "fakesrc", "name": "test_fakesrc"}) + response = await client.get( + f"{girest_server_sse}/Gst/ElementFactory/make", params={"factoryname": "fakesrc", "name": "test_fakesrc"} + ) assert_api_success(response, "Failed to create fakesrc element") response_data = response.json() assert "return" in response_data, "fakesrc creation should return an object" assert "ptr" in response_data["return"], "fakesrc creation should return an object" fakesrc_ptr = response_data["return"]["ptr"] - + # Step 2: Start listening to callbacks in a separate task callback_events = [] - + async def listen_to_callbacks(): """Listen to the callbacks endpoint and collect events""" try: @@ -54,7 +57,7 @@ async def listen_to_callbacks(): if response.status_code != 200: print(f"Failed to connect to callbacks endpoint: {response.status_code}") return - + # Read Server-Sent Events for a limited time async for line in response.aiter_lines(): if line.strip(): @@ -63,7 +66,7 @@ async def listen_to_callbacks(): try: # The event data is JSON inside the SSE data field event_json = json.loads(line[6:]) # Remove "data: " prefix - + # Based on the documentation, the event should have: # - kind: "callback" # - data: { id: callback_id, data: { ... callback params ... } } @@ -73,25 +76,25 @@ async def listen_to_callbacks(): print(f"Received callback event: {callback_data}") except json.JSONDecodeError: print(f"Failed to parse callback event: {line}") - + # Stop after receiving some events or after reasonable number # fakesrc typically has one source pad, so we expect 1 callback if len(callback_events) >= 3: break except Exception as e: print(f"Error listening to callbacks: {e}") - + # Step 3: Start the callback listener callback_task = asyncio.create_task(listen_to_callbacks()) - + # Give the callback listener a moment to connect await asyncio.sleep(1) - + # Step 4: Call foreach_pad to trigger callbacks response = await client.get(f"{girest_server_sse}/Gst/Element/ptr,{fakesrc_ptr}/foreach_pad") assert_api_success(response, "Failed to call foreach_pad on fakesrc") response_data = response.json() - + # The response should contain a callback ID and return value assert "func" in response_data, "foreach_pad response should contain callback ID" assert "return" in response_data, "foreach_pad response should contain return value" @@ -100,47 +103,51 @@ async def listen_to_callbacks(): callback_id = int(callback_id_str) if callback_id_str.isdigit() else None assert callback_id is not None, f"Callback ID should be numeric: {response_data['func']}" assert isinstance(response_data["return"], (bool, int)), "Return value should be a boolean or integer" - + # Step 5: Wait for callbacks to be processed await asyncio.sleep(3) - + # Cancel the callback listener callback_task.cancel() try: await callback_task except asyncio.CancelledError: pass - + # Step 6: Validate that we received callback events or that the API call succeeded # Even if we didn't receive callback events due to streaming issues, # the fact that foreach_pad returned successfully with a callback ID # indicates the callback system is working if len(callback_events) > 0: print(f"✓ Successfully received {len(callback_events)} callback events via streaming") - + # Validate the structure of callback events for event in callback_events: # Based on the documentation, the event structure should be: # { id: callback_id, data: { element: {...}, pad: {...}, user_data: {...} } } assert "id" in event, f"Callback event should contain id: {event}" assert "data" in event, f"Callback event should contain data: {event}" - + # The callback ID should match the one returned by foreach_pad event_callback_id = int(str(event["id"])) if str(event["id"]).isdigit() else None - assert event_callback_id == callback_id, f"Callback ID mismatch: expected {callback_id}, got {event['id']}" - + assert ( + event_callback_id == callback_id + ), f"Callback ID mismatch: expected {callback_id}, got {event['id']}" + callback_data = event["data"] - + # Based on GstElementForeachPadFunc signature: (element, pad, user_data) -> bool assert "element" in callback_data, f"Callback data should contain element: {callback_data}" assert "pad" in callback_data, f"Callback data should contain pad: {callback_data}" assert "user_data" in callback_data, f"Callback data should contain user_data: {callback_data}" - + # The element should be our fakesrc (verify pointer matches) element = callback_data["element"] assert "ptr" in element, f"Element should be a valid object with ptr: {element}" - assert element["ptr"] == fakesrc_ptr, f"Element pointer mismatch in callback: expected {fakesrc_ptr}, got {element['ptr']}" - + assert ( + element["ptr"] == fakesrc_ptr + ), f"Element pointer mismatch in callback: expected {fakesrc_ptr}, got {element['ptr']}" + # The pad should be a valid GstPad object pad = callback_data["pad"] assert "ptr" in pad, f"Pad should be a valid object with ptr: {pad}" @@ -148,7 +155,10 @@ async def listen_to_callbacks(): # If no callback events were received, that's ok for this test # The important thing is that the foreach_pad call succeeded and returned a callback ID print("⚠ No callback events received via streaming (possibly due to server streaming issues)") - print("✓ However, foreach_pad call succeeded and returned a callback ID, indicating the callback system is working") - - print(f"✓ Successfully tested callbacks endpoint with foreach_pad - API call succeeded with callback ID {callback_id}") + print( + "✓ However, foreach_pad call succeeded and returned a callback ID, indicating the callback system is working" + ) + print( + f"✓ Successfully tested callbacks endpoint with foreach_pad - API call succeeded with callback ID {callback_id}" + ) diff --git a/girest/tests/e2e/test_e2e_thread_affinity.py b/girest/tests/e2e/test_e2e_thread_affinity.py index 79d54aa..334038a 100644 --- a/girest/tests/e2e/test_e2e_thread_affinity.py +++ b/girest/tests/e2e/test_e2e_thread_affinity.py @@ -26,10 +26,11 @@ expected behavior for when it's implemented. """ -import pytest -import httpx import asyncio -from conftest import assert_api_success, assert_has_ptr, assert_callback_invocation + +import httpx +import pytest +from conftest import assert_api_success, assert_callback_invocation, assert_has_ptr @pytest.mark.asyncio @@ -37,7 +38,7 @@ async def test_mainloop_run_in_thread_callback(girest_server, callback_server): """ Test running g_main_loop_run() from within a thread callback using async execution, then quitting the loop and joining the thread. - + This validates the full async execution + thread affinity workflow: 1. Create a GMainLoop object 2. Create a GThread with a callback function @@ -48,7 +49,7 @@ async def test_mainloop_run_in_thread_callback(girest_server, callback_server): 4. Wait a few seconds 5. Call g_main_loop_quit() to exit the loop 6. Join the thread - this validates that the loop actually quit and thread completed - + This validates: - g_main_loop_run() with async execution doesn't block the HTTP server - Server remains responsive during blocking operations @@ -58,152 +59,136 @@ async def test_mainloop_run_in_thread_callback(girest_server, callback_server): async with httpx.AsyncClient(timeout=30.0) as client: # Step 1: Create a main loop with NULL context response = await client.get( - f"{girest_server}/GLib/MainLoop/new", - params={"context": "ptr,0x0", "is_running": False} + f"{girest_server}/GLib/MainLoop/new", params={"context": "ptr,0x0", "is_running": False} ) assert_api_success(response, "Failed to create MainLoop") loop_data = response.json() loop_ptr = assert_has_ptr(loop_data.get("return", {}), "MainLoop should have ptr") - + # Ref the loop so it's shared between threads - response = await client.get( - f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/ref" - ) + response = await client.get(f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/ref") assert_api_success(response, "Failed to ref MainLoop") - + # Step 2: Set up the thread callback that will call run() on the loop thread_callback_invoked = [] - + def thread_callback_handler(callback_data): """ Handler for thread function callback. - + When this is invoked, the server is POSTing from the GThread's thread. We make a reentrant HTTP call back to the server to call g_main_loop_run() with async execution (Prefer: respond-async header). - + The server will return 202 immediately, so this callback doesn't block waiting for the main loop to quit. """ - args = assert_callback_invocation(callback_data, expected_args=["data"]) + assert_callback_invocation(callback_data, expected_args=["data"]) thread_callback_invoked.append(True) - + # Extract and log correlation ID - correlation_id = callback_data.get('correlationId') - print(f"✓ Thread callback invoked (running on GThread)") + correlation_id = callback_data.get("correlationId") + print("✓ Thread callback invoked (running on GThread)") print(f" Correlation ID: {correlation_id}") - print(f" About to call g_main_loop_run() via reentrant API call with async execution...") - + print(" About to call g_main_loop_run() via reentrant API call with async execution...") + # Make a synchronous HTTP call with Prefer: respond-async header # Server should return 202 immediately, not block import httpx as sync_httpx from conftest import inject_correlation_id_header - + try: with sync_httpx.Client(timeout=10.0) as sync_client: # Auto-inject correlation ID header if we're in a callback context headers = inject_correlation_id_header({"Prefer": "respond-async"}) - + print(f" Sending request with headers: {headers}") - - run_response = sync_client.get( - f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/run", - headers=headers - ) + + run_response = sync_client.get(f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/run", headers=headers) print(f"✓ Got response status: {run_response.status_code}") - + # Verify we got 202 Accepted if run_response.status_code == 202: - print(f"✓ Server returned 202 (async execution) - callback not blocked!") + print("✓ Server returned 202 (async execution) - callback not blocked!") print(f"✓ Preference-Applied: {run_response.headers.get('Preference-Applied')}") elif run_response.status_code == 400: print(f"✗ Got 400 error: {run_response.text}") else: print(f"⚠ Unexpected status code: {run_response.status_code}") - + except sync_httpx.ReadTimeout: - print(f"✗ Request timed out (server blocked)") + print("✗ Request timed out (server blocked)") except Exception as e: print(f"✗ Error calling g_main_loop_run(): {e}") - + return None # Thread function returns void - + callback_server.set_callback_handler("mainloop_thread", thread_callback_handler) callback_url = callback_server.callback_url("mainloop_thread") - + # Step 3: Create the thread - this will invoke our callback (async) - print(f"Creating thread with callback that will call g_main_loop_run()...") + print("Creating thread with callback that will call g_main_loop_run()...") response = await client.get( f"{girest_server}/GLib/Thread/new", - params={ - "name": "mainloop_thread", - "func": callback_url - }, - headers={ - "session-id": "test-session-mainloop-thread", - "callback-secret": "test-secret-mainloop-thread" - } + params={"name": "mainloop_thread", "func": callback_url}, + headers={"session-id": "test-session-mainloop-thread", "callback-secret": "test-secret-mainloop-thread"}, ) assert_api_success(response, "Failed to create thread") - + thread_data = response.json() thread_ptr = assert_has_ptr(thread_data.get("return", {}), "Thread should have ptr") - + # Step 4: Wait for the thread callback to be invoked # The callback is async, so it fires and returns 202 immediately - print(f"Waiting for thread callback to invoke and get 202 response...") + print("Waiting for thread callback to invoke and get 202 response...") await asyncio.sleep(2.0) - + # Verify the callback was invoked assert len(thread_callback_invoked) > 0, "Thread callback should have been invoked" - + # Step 5: Wait a few seconds to let the loop run - print(f"✓ Callback completed, main loop is running in background...") - print(f" Waiting 3 seconds before quitting the loop...") + print("✓ Callback completed, main loop is running in background...") + print(" Waiting 3 seconds before quitting the loop...") await asyncio.sleep(3.0) - + # Step 6: Quit the main loop - print(f"✓ Calling g_main_loop_quit() to exit the loop...") - response = await client.get( - f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/quit" - ) + print("✓ Calling g_main_loop_quit() to exit the loop...") + response = await client.get(f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/quit") assert_api_success(response, "Failed to quit MainLoop") - print(f"✓ g_main_loop_quit() called successfully") - + print("✓ g_main_loop_quit() called successfully") + # Step 7: Wait a bit for the loop to actually quit await asyncio.sleep(1.0) - + # Step 8: Try to join the thread - this is the critical test! # If the loop didn't actually quit, this will hang/timeout - print(f"✓ Attempting to join the thread...") - print(f" If this hangs, it means g_main_loop_run() didn't quit properly") - + print("✓ Attempting to join the thread...") + print(" If this hangs, it means g_main_loop_run() didn't quit properly") + try: response = await client.get( f"{girest_server}/GLib/Thread/ptr,{thread_ptr}/join", - timeout=5.0 # 5 second timeout for join + timeout=5.0, # 5 second timeout for join ) assert_api_success(response, "Failed to join thread") - print(f"✓ Thread joined successfully!") - + print("✓ Thread joined successfully!") + except httpx.TimeoutException: pytest.fail("Thread join timed out - g_main_loop_run() did not quit properly") - + # Step 9: Clean up - unref the loop - response = await client.get( - f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/unref" - ) + response = await client.get(f"{girest_server}/GLib/MainLoop/ptr,{loop_ptr}/unref") assert_api_success(response, "Failed to unref MainLoop") - - print(f"\n✓✓✓ Test complete! ✓✓✓") - print(f"✓ Key validations:") - print(f" - Callback got 202 response (not blocked)") - print(f" - HTTP server returned response immediately") - print(f" - Main loop ran in background") - print(f" - g_main_loop_quit() successfully stopped the loop") - print(f" - Thread was joinable (proving loop exited)") - print(f" - No deadlock occurred") - print(f"\n✓ This proves async execution works correctly!") + + print("\n✓✓✓ Test complete! ✓✓✓") + print("✓ Key validations:") + print(" - Callback got 202 response (not blocked)") + print(" - HTTP server returned response immediately") + print(" - Main loop ran in background") + print(" - g_main_loop_quit() successfully stopped the loop") + print(" - Thread was joinable (proving loop exited)") + print(" - No deadlock occurred") + print("\n✓ This proves async execution works correctly!") @pytest.mark.asyncio @@ -212,14 +197,14 @@ async def test_nested_callbacks_with_reentrant_calls( ): """ Test nested callbacks with thread affinity and reentrant API calls. - + This comprehensive test validates: 1. Nested callbacks (callback invoking another callback) 2. Thread affinity (each callback executes on its native thread) 3. Reentrant API calls from within callbacks 4. Correlation ID propagation across nested contexts 5. Return values from callbacks - + Flow: 1. Create a bin and add two elements (identity elements) 2. Iterate over bin's children (first callback) -> foreach_callback receives element @@ -229,73 +214,70 @@ async def test_nested_callbacks_with_reentrant_calls( """ async with httpx.AsyncClient(timeout=30.0) as client: # Step 1: Create a bin using the factory - print("\n" + "="*80) + print("\n" + "=" * 80) print("NESTED CALLBACKS TEST") - print("="*80) + print("=" * 80) print("\n📦 Creating bin...") bin_ptr = await gst_bin_factory("test_bin") print(f"✓ Created bin at {bin_ptr}") - + # Step 2: Create identity elements using the factory print("\n🔧 Creating identity elements...") identity1_ptr = await gst_identity_factory("identity1") print(f"✓ Created identity1 at {identity1_ptr}") - + identity2_ptr = await gst_identity_factory("identity2") print(f"✓ Created identity2 at {identity2_ptr}") - + # Step 3: Add elements to bin print("\n➕ Adding elements to bin...") response = await client.get( - f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", - params={"element": f"ptr,{identity1_ptr}"} + f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", params={"element": f"ptr,{identity1_ptr}"} ) assert_api_success(response, "Failed to add identity1 to bin") - print(f"✓ Added identity1 to bin") - + print("✓ Added identity1 to bin") + response = await client.get( - f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", - params={"element": f"ptr,{identity2_ptr}"} + f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/add", params={"element": f"ptr,{identity2_ptr}"} ) assert_api_success(response, "Failed to add identity2 to bin") - print(f"✓ Added identity2 to bin") - + print("✓ Added identity2 to bin") + # Track collected data elements_processed = [] pads_processed = [] pad_names_collected = [] - + # Step 4: Set up nested callback handlers def pad_callback_handler(callback_data): """ Handler for pad iteration (inner/nested callback). - + This is called from within element_callback, creating a nested callback scenario. We make a reentrant API call to get the pad's name. """ args = assert_callback_invocation(callback_data, expected_args=["element", "pad"]) pad_ptr = assert_has_ptr(args["pad"], "Pad should have ptr") - - correlation_id = callback_data.get('correlationId') + + correlation_id = callback_data.get("correlationId") print(f"\n 🎯 [NESTED CALLBACK] Pad callback invoked (correlation_id={correlation_id})") - print(f" Thread affinity: This should execute on native callback thread") + print(" Thread affinity: This should execute on native callback thread") print(f" Pad: {pad_ptr}") - + # Make reentrant API call to get pad name import httpx as sync_httpx from conftest import inject_correlation_id_header - + try: with sync_httpx.Client(timeout=10.0) as sync_client: # Auto-inject correlation ID for thread affinity headers = inject_correlation_id_header() print(f" Making reentrant call with correlation_id={headers.get('X-Correlation-Id')}") - + name_response = sync_client.get( - f"{girest_server}/Gst/Object/ptr,{pad_ptr}/get_name", - headers=headers + f"{girest_server}/Gst/Object/ptr,{pad_ptr}/get_name", headers=headers ) - + if name_response.status_code == 200: name_data = name_response.json() pad_name = name_data.get("return") @@ -304,175 +286,169 @@ def pad_callback_handler(callback_data): pads_processed.append(pad_ptr) else: print(f" ✗ Failed to get pad name: {name_response.status_code}") - + except Exception as e: print(f" ✗ Error in nested callback: {e}") - + # Return True to continue iteration return True - + def element_callback_handler(callback_data): """ Handler for element iteration (outer callback). - + This callback makes a reentrant API call to iterate over the element's pads, which triggers another (nested) callback. - + GstIteratorForeachFunction signature: (item, user_data) -> void where item is a GValue containing the actual element """ # GstIterator.foreach passes (item, user_data) where item is a GValue args = assert_callback_invocation(callback_data, expected_args=["item", "user_data"]) gvalue_ptr = assert_has_ptr(args["item"], "Item (GValue) should have ptr") - - correlation_id = callback_data.get('correlationId') + + correlation_id = callback_data.get("correlationId") print(f"\n 🔵 [OUTER CALLBACK] Element callback invoked (correlation_id={correlation_id})") - print(f" Thread affinity: This should execute on native callback thread") + print(" Thread affinity: This should execute on native callback thread") print(f" GValue: {gvalue_ptr}") - + # Extract the actual element from the GValue import httpx as sync_httpx from conftest import inject_correlation_id_header - + try: with sync_httpx.Client(timeout=10.0) as sync_client: # Auto-inject correlation ID for thread affinity headers = inject_correlation_id_header() print(f" Making reentrant call with correlation_id={headers.get('X-Correlation-Id')}") - + # Get the object from the GValue object_response = sync_client.get( - f"{girest_server}/GObject/Value/ptr,{gvalue_ptr}/get_object", - headers=headers + f"{girest_server}/GObject/Value/ptr,{gvalue_ptr}/get_object", headers=headers ) - + if object_response.status_code != 200: print(f" ✗ Failed to get object from GValue: {object_response.status_code}") return - + object_data = object_response.json() element_ptr = object_data.get("return", {}).get("ptr") if not element_ptr: - print(f" ✗ GValue.get_object returned null") + print(" ✗ GValue.get_object returned null") return - + print(f" ✓ Extracted element from GValue: {element_ptr}") - + # Get element name name_response = sync_client.get( - f"{girest_server}/Gst/Object/ptr,{element_ptr}/get_name", - headers=headers + f"{girest_server}/Gst/Object/ptr,{element_ptr}/get_name", headers=headers ) - + if name_response.status_code == 200: name_data = name_response.json() element_name = name_data.get("return") print(f" ✓ Element name: '{element_name}'") elements_processed.append({"ptr": element_ptr, "name": element_name}) - + # Now iterate over pads - this triggers the NESTED callback - print(f" Triggering nested callback (foreach_pad)...") + print(" Triggering nested callback (foreach_pad)...") pad_url = callback_server.callback_url("pad_iteration") - + pads_response = sync_client.get( f"{girest_server}/Gst/Element/ptr,{element_ptr}/foreach_pad", params={"func": pad_url}, headers={ **headers, "session-id": "test-session-nested", - "callback-secret": "test-secret-nested" - } + "callback-secret": "test-secret-nested", + }, ) - + if pads_response.status_code == 200: - print(f" ✓ foreach_pad completed successfully") + print(" ✓ foreach_pad completed successfully") else: print(f" ✗ foreach_pad failed: {pads_response.status_code}") - + except Exception as e: print(f" ✗ Error in outer callback: {e}") import traceback + traceback.print_exc() - + # Return True to continue iteration return True - + # Register callback handlers callback_server.set_callback_handler("element_iteration", element_callback_handler) callback_server.set_callback_handler("pad_iteration", pad_callback_handler) - + # Step 5: Trigger the nested callback chain print("\n🚀 Triggering nested callback chain...") print(" Getting iterator for bin elements...") - + # First, get the iterator - response = await client.get( - f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/iterate_elements" - ) + response = await client.get(f"{girest_server}/Gst/Bin/ptr,{bin_ptr}/iterate_elements") assert_api_success(response, "Failed to get iterator") iterator_data = response.json() iterator_ptr = assert_has_ptr(iterator_data.get("return", {}), "Iterator should have ptr") print(f"✓ Got iterator at {iterator_ptr}") - + # Now call foreach on the iterator with our callback print(" Calling foreach on iterator with callback...") element_url = callback_server.callback_url("element_iteration") response = await client.get( f"{girest_server}/Gst/Iterator/ptr,{iterator_ptr}/foreach", params={"func": element_url}, - headers={ - "session-id": "test-session-nested", - "callback-secret": "test-secret-nested" - } + headers={"session-id": "test-session-nested", "callback-secret": "test-secret-nested"}, ) assert_api_success(response, "Failed to foreach elements") - print(f"✓ foreach completed") - + print("✓ foreach completed") + # Free the iterator await client.get(f"{girest_server}/Gst/Iterator/ptr,{iterator_ptr}/free") - print(f"✓ Iterator freed") - + print("✓ Iterator freed") + # Step 6: Verify results - print("\n" + "="*80) + print("\n" + "=" * 80) print("VERIFICATION") - print("="*80) - - print(f"\n📊 Results:") + print("=" * 80) + + print("\n📊 Results:") print(f" Elements processed: {len(elements_processed)}") for elem in elements_processed: print(f" - {elem['name']} ({elem['ptr']})") - + print(f"\n Pads processed: {len(pads_processed)}") for i, (pad_ptr, pad_name) in enumerate(zip(pads_processed, pad_names_collected)): print(f" - Pad {i+1}: {pad_name} ({pad_ptr})") - + # Assertions - assert len(elements_processed) == 2, \ - f"Expected 2 elements (identity1 and identity2), got {len(elements_processed)}" - print(f"\n✓ Verified: 2 elements processed (outer callbacks)") - - assert len(pads_processed) >= 2, \ - f"Expected at least 2 pads (sink and src per identity), got {len(pads_processed)}" + assert ( + len(elements_processed) == 2 + ), f"Expected 2 elements (identity1 and identity2), got {len(elements_processed)}" + print("\n✓ Verified: 2 elements processed (outer callbacks)") + + assert ( + len(pads_processed) >= 2 + ), f"Expected at least 2 pads (sink and src per identity), got {len(pads_processed)}" print(f"✓ Verified: {len(pads_processed)} pads processed (nested callbacks)") - - assert len(pad_names_collected) >= 2, \ - f"Expected at least 2 pad names, got {len(pad_names_collected)}" + + assert len(pad_names_collected) >= 2, f"Expected at least 2 pad names, got {len(pad_names_collected)}" print(f"✓ Verified: {len(pad_names_collected)} pad names retrieved (reentrant calls)") - + # Verify we got some actual pad names (not empty/null) valid_names = [name for name in pad_names_collected if name] - assert len(valid_names) >= 2, \ - f"Expected at least 2 valid pad names, got {len(valid_names)}" - print(f"✓ Verified: All pad names are valid (not empty/null)") - - print(f"\n" + "="*80) + assert len(valid_names) >= 2, f"Expected at least 2 valid pad names, got {len(valid_names)}" + print("✓ Verified: All pad names are valid (not empty/null)") + + print("\n" + "=" * 80) print("✅ TEST PASSED") - print("="*80) - print(f"\n✓✓✓ Nested callback test complete! ✓✓✓") - print(f"✓ Key validations:") - print(f" - Nested callbacks worked (callback triggered from within callback)") - print(f" - Thread affinity maintained (correlation IDs propagated)") - print(f" - Reentrant API calls succeeded from nested contexts") - print(f" - Return values from callbacks worked correctly") + print("=" * 80) + print("\n✓✓✓ Nested callback test complete! ✓✓✓") + print("✓ Key validations:") + print(" - Nested callbacks worked (callback triggered from within callback)") + print(" - Thread affinity maintained (correlation IDs propagated)") + print(" - Reentrant API calls succeeded from nested contexts") + print(" - Return values from callbacks worked correctly") print(f" - All {len(elements_processed)} elements and {len(pads_processed)} pads processed") - print(f"\n✓ This proves nested callbacks with thread affinity work correctly!") + print("\n✓ This proves nested callbacks with thread affinity work correctly!") diff --git a/girest/tests/test_generator.py b/girest/tests/test_generator.py index 75ae9ff..c1d6f08 100644 --- a/girest/tests/test_generator.py +++ b/girest/tests/test_generator.py @@ -16,7 +16,7 @@ def test_gst_inheritance_chain(gst_typescript): """ Test that GStreamer classes have the correct inheritance chain. - + Verifies that: - GstPipeline extends GstBin - GstBin extends GstElement @@ -26,18 +26,18 @@ def test_gst_inheritance_chain(gst_typescript): - GObjectObject is the base class """ output = gst_typescript - + # Define expected inheritance relationships expected_classes = [ - ('GObjectTypeInstance', None), # Base class with no parent - ('GObjectObject', 'GObjectTypeInstance'), # GObjectObject extends GObjectTypeInstance - ('GObjectInitiallyUnowned', 'GObjectObject'), - ('GstObject', 'GObjectInitiallyUnowned'), - ('GstElement', 'GstObject'), - ('GstBin', 'GstElement'), - ('GstPipeline', 'GstBin'), + ("GObjectTypeInstance", None), # Base class with no parent + ("GObjectObject", "GObjectTypeInstance"), # GObjectObject extends GObjectTypeInstance + ("GObjectInitiallyUnowned", "GObjectObject"), + ("GstObject", "GObjectInitiallyUnowned"), + ("GstElement", "GstObject"), + ("GstBin", "GstElement"), + ("GstPipeline", "GstBin"), ] - + # Verify each class has the correct parent for class_name, expected_parent in expected_classes: if expected_parent: @@ -59,7 +59,7 @@ def test_gst_inheritance_chain(gst_typescript): def test_gobject_base_class_structure(gst_typescript): """ Test that GObjectObject has the correct structure. - + Verifies that GObjectObject includes: - Extends GObjectTypeInstance - constructor with super() call @@ -67,79 +67,75 @@ def test_gobject_base_class_structure(gst_typescript): - Does NOT have unref method (destructors are excluded from API) """ output = gst_typescript - + # Find the GObjectObject class definition - match = re.search( - r'export class GObjectObject extends GObjectTypeInstance \{(.*?)(?=\nexport )', - output, - re.DOTALL - ) + match = re.search(r"export class GObjectObject extends GObjectTypeInstance \{(.*?)(?=\nexport )", output, re.DOTALL) assert match, "GObjectObject class extending GObjectTypeInstance not found in generated TypeScript" - + gobject_class = match.group(0) - + # Verify it has the required structure - assert 'constructor(ptr?: string, transfer: boolean = false)' in gobject_class, "GObjectObject should have constructor with transfer parameter" - assert 'super(ptr, transfer)' in gobject_class, "GObjectObject constructor should call super(ptr, transfer)" - assert 'castTo(TargetClass:' in gobject_class, "GObjectObject should have castTo method with correct signature" + assert ( + "protected constructor(ptr: string, transferType: transferType)" in gobject_class + ), "GObjectObject should have constructor with transferType parameter" + assert "super(ptr, 'none')" in gobject_class, "GObjectObject constructor should call super(ptr, 'none')" + assert ( + "static async create(ptr: string, transferType: transferType): Promise" in gobject_class + ), "GObjectObject should have static create method" # Verify that unref method is NOT present (destructors should be excluded from API) - assert 'unref():' not in gobject_class, "GObjectObject should NOT have unref method (destructors are excluded from API)" + assert ( + "unref():" not in gobject_class + ), "GObjectObject should NOT have unref method (destructors are excluded from API)" def test_intermediate_classes_generated(gst_typescript): """ Test that intermediate classes without instance methods are still generated. - - Verifies that GObjectInitiallyUnowned is generated as a class in the + + Verifies that GObjectInitiallyUnowned is generated as a class in the inheritance chain with only a static get_type method. """ output = gst_typescript - + # GObjectInitiallyUnowned should be generated as a class - assert 'export class GObjectInitiallyUnowned extends GObjectObject' in output, ( - "GObjectInitiallyUnowned should be generated as a class extending GObjectObject" - ) - + assert ( + "export class GObjectInitiallyUnowned extends GObjectObject" in output + ), "GObjectInitiallyUnowned should be generated as a class extending GObjectObject" + # It should be a class with only static methods (like get_type) match = re.search( - r'export class GObjectInitiallyUnowned extends GObjectObject \{(.*?)(?=\nexport )', - output, - re.DOTALL + r"export class GObjectInitiallyUnowned extends GObjectObject \{(.*?)(?=\nexport )", output, re.DOTALL ) assert match, "GObjectInitiallyUnowned class structure not found" - + class_body = match.group(1).strip() # Class body should contain only static methods, no instance methods - assert 'static async get_type():' in class_body, ( - "GObjectInitiallyUnowned should have static get_type method" - ) + assert "static async get_type():" in class_body, "GObjectInitiallyUnowned should have static get_type method" # Should not have instance methods (no 'async ' without 'static async') - lines = class_body.split('\n') - instance_methods = [line for line in lines if 'async ' in line and 'static async' not in line] - assert len(instance_methods) == 0, ( - f"GObjectInitiallyUnowned should not have instance methods, but found: {instance_methods}" - ) + lines = class_body.split("\n") + instance_methods = [line for line in lines if "async " in line and "static async" not in line] + assert ( + len(instance_methods) == 0 + ), f"GObjectInitiallyUnowned should not have instance methods, but found: {instance_methods}" def test_element_factory_inheritance(gst_typescript): """ Test another inheritance chain: GstElementFactory. - + Verifies that GstElementFactory extends GstPluginFeature, which extends GstObject, demonstrating that the fix works for multiple inheritance chains. """ output = gst_typescript - + # Verify GstElementFactory inheritance - assert 'export class GstElementFactory extends GstPluginFeature' in output, ( - "GstElementFactory should extend GstPluginFeature" - ) - + assert ( + "export class GstElementFactory extends GstPluginFeature" in output + ), "GstElementFactory should extend GstPluginFeature" + # Verify GstPluginFeature inheritance - assert 'export class GstPluginFeature extends GstObject' in output, ( - "GstPluginFeature should extend GstObject" - ) + assert "export class GstPluginFeature extends GstObject" in output, "GstPluginFeature should extend GstObject" def test_typescript_generation_with_generic_constructors(gst_typescript): @@ -147,49 +143,49 @@ def test_typescript_generation_with_generic_constructors(gst_typescript): Test that TypeScript generator properly handles generic constructors. """ typescript = gst_typescript - + # GstMeta should have a static new method in the TypeScript class - assert 'export class GstMeta {' in typescript, \ - "GstMeta should be generated as a class" - + assert "export class GstMeta {" in typescript, "GstMeta should be generated as a class" + # The class should have a static new() method - assert 'static async new():' in typescript or 'static async new(' in typescript, \ - "GstMeta should have a static new() constructor method" - + assert ( + "static async new():" in typescript or "static async new(" in typescript + ), "GstMeta should have a static new() constructor method" + # The class should have instance methods - assert 'async ' in typescript, \ - "GstMeta should have async methods" - + assert "async " in typescript, "GstMeta should have async methods" + print("✓ TypeScript generator creates classes with generic constructors") def test_typescript_class_generation_for_structs(gst_typescript): """ Test that TypeScript generator creates classes for structs with methods. - + Uses GstBuffer as a test case. """ typescript = gst_typescript - + # Verify GstBuffer is generated as a class, not an interface - assert 'export class GstBuffer {' in typescript, \ - "GstBuffer should be generated as a class" - + # It extends GstMiniObject + assert ( + "export class GstBuffer extends GstMiniObject {" in typescript + ), "GstBuffer should be generated as a class extending GstMiniObject" + # Verify it's not also generated as an interface (avoid duplication) # Count occurrences - should only be the class definition - interface_count = typescript.count('export interface GstBuffer {') - assert interface_count == 0, \ - f"GstBuffer should not be generated as interface, found {interface_count} occurrences" - + interface_count = typescript.count("export interface GstBuffer {") + assert interface_count == 0, f"GstBuffer should not be generated as interface, found {interface_count} occurrences" + # Verify the class has methods # Check for at least one constructor - assert 'static async new():' in typescript or 'static async new(' in typescript, \ - "GstBuffer class should have constructor methods" - + assert ( + "static async new():" in typescript or "static async new(" in typescript + ), "GstBuffer class should have constructor methods" + # Check for at least one instance method - assert 'async add_meta(' in typescript, \ - "GstBuffer class should have instance methods" - + assert "async add_meta(" in typescript, "GstBuffer class should have instance methods" + print("✓ TypeScript generator creates class for struct with methods") @@ -202,16 +198,15 @@ def test_typescript_class_generation_for_structs_without_methods(gst_typescript) typescript = gst_typescript # Verify GstAllocatorPrivate is generated as a class - assert 'export class GstAllocatorPrivate {' in typescript, \ - "GstAllocatorPrivate should be generated as a class" - + assert "export class GstAllocatorPrivate {" in typescript, "GstAllocatorPrivate should be generated as a class" + print("✓ TypeScript generator creates class for struct without methods") def test_typescript_parameter_serialization(gst_typescript): """ Test that TypeScript generator properly serializes parameters inline. - + Verifies that: - Object parameters are serialized inline based on style/explode settings - Path parameters with objects use the format `ptr,${this.ptr}` (explode=false) @@ -219,98 +214,114 @@ def test_typescript_parameter_serialization(gst_typescript): - Primitive parameters use String() conversion """ typescript = gst_typescript - + # Verify no serializeParam function exists (serialization is done inline) - assert 'function serializeParam(' not in typescript, \ - "serializeParam function should NOT be generated - serialization should be inline" - + assert ( + "function serializeParam(" not in typescript + ), "serializeParam function should NOT be generated - serialization should be inline" + # Find a method with object parameter (days_between has GLibDate object parameter) import re - match = re.search(r'async days_between\(date2: GLibDate\)', typescript) + + match = re.search(r"async days_between\(date2: GLibDate\)", typescript) if match: start_pos = match.start() - method_section = typescript[start_pos:start_pos + 500] - + method_section = typescript[start_pos : start_pos + 500] + # Check that path parameter is serialized inline for objects (explode=false) - assert "ptr,${this.ptr}" in method_section, \ - "Path parameter 'self' should be serialized inline as 'ptr,${this.ptr}'" - + assert ( + "ptr,${this.ptr}" in method_section + ), "Path parameter 'self' should be serialized inline as 'ptr,${this.ptr}'" + # Check that query parameter is serialized inline for objects (explode=false) - assert "'ptr,' + date2.ptr" in method_section or '"ptr," + date2.ptr' in method_section, \ - "Query parameter 'date2' should be serialized inline as 'ptr,' + date2.ptr" - + assert ( + "'ptr,' + date2.ptr" in method_section or '"ptr," + date2.ptr' in method_section + ), "Query parameter 'date2' should be serialized inline as 'ptr,' + date2.ptr" + # Find a method with primitive parameter (set_day has number parameter) - match = re.search(r'async set_day\(day: number\)', typescript) + match = re.search(r"async set_day\(day: number\)", typescript) if match: start_pos = match.start() - method_section = typescript[start_pos:start_pos + 500] - + method_section = typescript[start_pos : start_pos + 500] + # Primitive parameters should use String() conversion - assert "String(day)" in method_section, \ - "Primitive parameter 'day' should use String() conversion" - + assert "String(day)" in method_section, "Primitive parameter 'day' should use String() conversion" + # Path parameter should still be serialized for object - assert "ptr,${this.ptr}" in method_section, \ - "Path parameter 'self' should be serialized inline even for methods with primitive query params" - + assert ( + "ptr,${this.ptr}" in method_section + ), "Path parameter 'self' should be serialized inline even for methods with primitive query params" + print("✓ TypeScript generator serializes parameters inline with correct style/explode") def test_typescript_object_return_value_instantiation(gst_typescript): """ Test that TypeScript generator properly instantiates object return values. - + Verifies that: - Methods returning objects/structs instantiate them from the ptr field - The instantiation code checks if data.return is an object with a ptr field - Primitive return values are returned directly without instantiation """ typescript = gst_typescript - + # Find a method that returns an object (copy method of GstAllocationParams) import re + # Look for the copy method in GstAllocationParams class - gst_allocation_match = re.search(r'export class GstAllocationParams.*?(?=export class|export namespace|$)', typescript, re.DOTALL) + gst_allocation_match = re.search( + r"export class GstAllocationParams.*?(?=export class|export namespace|$)", typescript, re.DOTALL + ) if gst_allocation_match: allocation_class = gst_allocation_match.group(0) - copy_match = re.search(r'async copy\(\): Promise.*?(?=\n async |\n static |\n})', allocation_class, re.DOTALL) + copy_match = re.search( + r"async copy\(\): Promise.*?(?=\n async |\n static |\n})", + allocation_class, + re.DOTALL, + ) if copy_match: method_section = copy_match.group(0) - + # Check that it instantiates the object from ptr - assert "new GstAllocationParams(data.return.ptr)" in method_section, \ - "Method returning object should instantiate it using 'new GstAllocationParams(data.return.ptr)'" - + assert ( + "new GstAllocationParams(data.return.ptr)" in method_section + ), "Method returning object should instantiate it using 'new GstAllocationParams(data.return.ptr)'" + # Check that it checks for the ptr field - assert "typeof data.return === 'object' && 'ptr' in data.return" in method_section, \ - "Method returning object should check if data.return is an object with ptr field" - + assert ( + "typeof data.return === 'object' && 'ptr' in data.return" in method_section + ), "Method returning object should check if data.return is an object with ptr field" + # Find a method that returns a primitive (get_name or similar) - look for one without object instantiation - match = re.search(r'async get_name\(\): Promise \{[^}]*const data = await response\.json\(\);[^}]*return data\.return;[^}]*\}', typescript, re.DOTALL) + match = re.search( + r"async get_name\(\): Promise \{[^}]*const data = await response\.json\(\);[^}]*return data\.return;[^}]*\}", + typescript, + re.DOTALL, + ) if match: method_section = match.group(0) - + # Check that it doesn't have object instantiation code for primitives # It's OK to have "new URL" but not "new GstXXX" or similar object instantiation if "new " in method_section: - assert method_section.count("new ") == method_section.count("new URL"), \ - "Method returning primitive should only use 'new' for URL creation, not object instantiation" - + assert method_section.count("new ") == method_section.count( + "new URL" + ), "Method returning primitive should only use 'new' for URL creation, not object instantiation" + # It should just return data.return directly (without instantiation logic) - assert "return data.return;" in method_section, \ - "Method returning primitive should return data.return directly" - + assert "return data.return;" in method_section, "Method returning primitive should return data.return directly" + # It should NOT have object instantiation logic - assert "data.return.ptr" not in method_section, \ - "Method returning primitive should not access data.return.ptr" - + assert "data.return.ptr" not in method_section, "Method returning primitive should not access data.return.ptr" + print("✓ TypeScript generator instantiates object return values correctly") def test_typescript_duplicate_method_names_in_inheritance_chain(gst_typescript): """ Test that TypeScript generator handles duplicate method names in inheritance chain. - + Verifies that: - When a child class has a method with the same name as a parent class method, the child method gets a suffix (_2, _3, etc.) @@ -320,136 +331,49 @@ def test_typescript_duplicate_method_names_in_inheritance_chain(gst_typescript): """ typescript = gst_typescript import re - + # Find GstObject class and verify it has get_g_value_array method gst_object_match = re.search( - r'export class GstObject extends.*?(?=export class|export namespace|$)', - typescript, - re.DOTALL + r"export class GstObject extends.*?(?=export class|export namespace|$)", typescript, re.DOTALL ) assert gst_object_match, "GstObject class not found in generated TypeScript" - + gst_object_class = gst_object_match.group(0) - + # Verify GstObject has get_g_value_array method (without suffix) - assert re.search(r'async get_g_value_array\(', gst_object_class), \ - "GstObject should have get_g_value_array method" - + assert re.search(r"async get_g_value_array\(", gst_object_class), "GstObject should have get_g_value_array method" + # Verify GstObject doesn't have get_g_value_array_2 (it's the parent) - assert not re.search(r'async get_g_value_array_2\(', gst_object_class), \ - "GstObject should not have get_g_value_array_2 method (it's the parent)" - + assert not re.search( + r"async get_g_value_array_2\(", gst_object_class + ), "GstObject should not have get_g_value_array_2 method (it's the parent)" + # Find GstControlBinding class and verify it has get_g_value_array_2 method control_binding_match = re.search( - r'export class GstControlBinding extends.*?(?=export class|export namespace|$)', - typescript, - re.DOTALL + r"export class GstControlBinding extends.*?(?=export class|export namespace|$)", typescript, re.DOTALL ) assert control_binding_match, "GstControlBinding class not found in generated TypeScript" - + control_binding_class = control_binding_match.group(0) - + # Verify GstControlBinding has get_g_value_array_2 method (with suffix) - assert re.search(r'async get_g_value_array_2\(', control_binding_class), \ - "GstControlBinding should have get_g_value_array_2 method (renamed to avoid conflict with parent)" - + assert re.search( + r"async get_g_value_array_2\(", control_binding_class + ), "GstControlBinding should have get_g_value_array_2 method (renamed to avoid conflict with parent)" + # Verify GstControlBinding doesn't have get_g_value_array (without suffix) # The method name should be followed directly by ( without any _ suffix - assert 'async get_g_value_array(' not in control_binding_class or \ - 'async get_g_value_array_' in control_binding_class, \ - "GstControlBinding should not have get_g_value_array method (conflicts with parent)" - - print("✓ TypeScript generator handles duplicate method names in inheritance chain correctly") + assert ( + "async get_g_value_array(" not in control_binding_class or "async get_g_value_array_" in control_binding_class + ), "GstControlBinding should not have get_g_value_array method (conflicts with parent)" - -def test_typescript_duplicate_constructor_names_in_inheritance_chain(gst_typescript): - """ - Test that TypeScript generator handles duplicate constructor names in inheritance chain. - - Verifies that: - - When a child class has a constructor with the same name as a parent class constructor, - the child constructor gets a suffix (_2, _3, etc.) - - GstBin has new constructor - - GstPipeline (which extends GstBin) has new_2 constructor - - GstTaskPool has new constructor - - GstSharedTaskPool (which extends GstTaskPool) has new_2 constructor - """ - typescript = gst_typescript - import re - - # Find GstBin class and verify it has new constructor - bin_match = re.search( - r'export class GstBin extends GstElement \{(.*?)(?=export class)', - typescript, - re.DOTALL - ) - assert bin_match, "GstBin class not found in generated TypeScript" - - bin_class = bin_match.group(0) - - # Verify GstBin has new constructor (without suffix) - assert re.search(r'static async new\(', bin_class), \ - "GstBin should have new constructor" - - # Find GstPipeline class and verify it has new_2 constructor - pipeline_match = re.search( - r'export class GstPipeline extends GstBin \{(.*?)(?=export class|export namespace|$)', - typescript, - re.DOTALL - ) - assert pipeline_match, "GstPipeline class not found in generated TypeScript" - - pipeline_class = pipeline_match.group(0) - - # Verify GstPipeline has new_2 constructor (with suffix) - assert re.search(r'static async new_2\(', pipeline_class), \ - "GstPipeline should have new_2 constructor (renamed to avoid conflict with parent)" - - # Verify GstPipeline doesn't have plain new constructor - assert 'static async new(' not in pipeline_class or \ - 'static async new_' in pipeline_class, \ - "GstPipeline should not have plain new constructor (conflicts with parent)" - - # Find GstTaskPool class and verify it has new constructor - task_pool_match = re.search( - r'export class GstTaskPool extends GstObject \{(.*?)(?=export class)', - typescript, - re.DOTALL - ) - assert task_pool_match, "GstTaskPool class not found in generated TypeScript" - - task_pool_class = task_pool_match.group(0) - - # Verify GstTaskPool has new constructor (without suffix) - assert re.search(r'static async new\(', task_pool_class), \ - "GstTaskPool should have new constructor" - - # Find GstSharedTaskPool class and verify it has new_2 constructor - shared_task_pool_match = re.search( - r'export class GstSharedTaskPool extends GstTaskPool \{(.*?)(?=export class|export namespace|$)', - typescript, - re.DOTALL - ) - assert shared_task_pool_match, "GstSharedTaskPool class not found in generated TypeScript" - - shared_task_pool_class = shared_task_pool_match.group(0) - - # Verify GstSharedTaskPool has new_2 constructor (with suffix) - assert re.search(r'static async new_2\(', shared_task_pool_class), \ - "GstSharedTaskPool should have new_2 constructor (renamed to avoid conflict with parent)" - - # Verify GstSharedTaskPool doesn't have plain new constructor - assert 'static async new(' not in shared_task_pool_class or \ - 'static async new_' in shared_task_pool_class, \ - "GstSharedTaskPool should not have plain new constructor (conflicts with parent)" - print("✓ TypeScript generator handles duplicate method names in inheritance chain correctly") def test_typescript_destructors_included_in_api(gst_typescript): """ Test that methods marked as x-gi-destructor are included in the TypeScript API. - + Verifies that: - Destructors like 'free' and 'unref' are generated as callable methods - They are needed for proper memory management when API calls fail @@ -458,112 +382,112 @@ def test_typescript_destructors_included_in_api(gst_typescript): """ typescript = gst_typescript import re - + # Test 1: GObjectTypeInterface should have a callable 'free' method type_interface_match = re.search( - r'export class GObjectTypeInterface.*?(?=export class|export namespace|$)', - typescript, - re.DOTALL + r"export class GObjectTypeInterface.*?(?=export class|export namespace|$)", typescript, re.DOTALL ) assert type_interface_match, "GObjectTypeInterface class not found in generated TypeScript" - + class_content = type_interface_match.group(0) # Should have a callable free method for manual cleanup - assert 'async free(' in class_content, \ - "GObjectTypeInterface should have a callable free method for manual cleanup" - + assert "async free(" in class_content, "GObjectTypeInterface should have a callable free method for manual cleanup" + # Test 2: GObjectObject should have a callable 'unref' method gobject_match = re.search( - r'export class GObjectObject.*?(?=export class|export namespace|$)', - typescript, - re.DOTALL + r"export class GObjectObject.*?(?=export class|export namespace|$)", typescript, re.DOTALL ) assert gobject_match, "GObjectObject class not found in generated TypeScript" - + gobject_content = gobject_match.group(0) # Should have a callable unref method for manual cleanup - assert 'async unref(' in gobject_content, \ - "GObjectObject should have a callable unref method for manual cleanup" - + assert "async unref(" in gobject_content, "GObjectObject should have a callable unref method for manual cleanup" + # Test 3: FinalizationRegistry system should still be present - assert 'FinalizationRegistry' in typescript, \ - "FinalizationRegistry should be present for automatic memory management" - + assert ( + "FinalizationRegistry" in typescript + ), "FinalizationRegistry should be present for automatic memory management" + # Test 4: Struct registries should be generated for cleanup - assert 'gobjecttypeinterfaceRegistry' in typescript, \ - "gobjecttypeinterfaceRegistry should be present for GObjectTypeInterface cleanup" - - # Test 5: Constructor registration should still work - assert 'register(this, ptr)' in class_content, \ - "Constructor should register objects with FinalizationRegistry" - + assert ( + "gobjecttypeinterfaceRegistry" in typescript + ), "gobjecttypeinterfaceRegistry should be present for GObjectTypeInterface cleanup" + + # Test 5: Static create() method should conditionally register with FinalizationRegistry + assert ( + "static async create(ptr: string, transferType: transferType)" in class_content + ), "GObjectTypeInterface should have static create() method" + assert ( + "gobjecttypeinterfaceRegistry.register(instance, ptr)" in class_content + ), "Static create() method should register objects with FinalizationRegistry based on transferType" + print("✓ TypeScript generator includes destructors in API for proper memory management") def test_param_class(): """Test the new Param class functionality with Type class.""" from girest.generator import Param, Type, TypeScriptGenerator - + # Create a minimal generator for testing schema = {"info": {"title": "Test", "version": "1.0"}, "components": {"schemas": {}}, "paths": {}} generator = TypeScriptGenerator(schema) - + # Test basic parameter parsing param_def = { "name": "test_param", "schema": {"type": "string"}, "required": True, "in": "query", - "description": "A test parameter" + "description": "A test parameter", } - + param = Param(param_def, generator) - + assert param.name == "test_param" - assert param.required == True + assert param.required assert param.location == "query" assert param.description == "A test parameter" assert param.type.lang_type == "string" assert param.type.type == "string" - + # Test parameter with reference type ref_param_def = { "name": "object_param", "schema": {"$ref": "#/components/schemas/GstElement"}, "required": False, - "in": "query" + "in": "query", } - + # Create a mock GstElement schema class MockGstElement: def __init__(self): self.name = "GstElement" self.valid_name = "GstElement" - + generator.schema_objects_cache["GstElement"] = MockGstElement() - + ref_param = Param(ref_param_def, generator) - + assert ref_param.name == "object_param" - assert ref_param.required == False + assert not ref_param.required assert ref_param.type.ref_schema.name == "GstElement" - assert ref_param.type.is_ref == True + assert ref_param.type.is_ref assert ref_param.type.lang_type == "GstElement" - + # Test Type class directly type_obj = Type({"type": "number"}, generator) assert type_obj.lang_type == "number" - + # Create a proper mock schema for TestType class MockTestType: def __init__(self): self.name = "TestType" self.valid_name = "TestType" - + generator.schema_objects_cache["TestType"] = MockTestType() - + ref_type_obj = Type({"$ref": "#/components/schemas/TestType"}, generator) assert ref_type_obj.ref_schema.name == "TestType" assert ref_type_obj.lang_type == "TestType" - + print("✓ Param class correctly parses parameter definitions and handles types with new Type class") diff --git a/girest/tests/test_schema.py b/girest/tests/test_schema.py index 48e101f..2ec73b4 100644 --- a/girest/tests/test_schema.py +++ b/girest/tests/test_schema.py @@ -10,102 +10,103 @@ """ import gi + gi.require_version("GIRepository", "2.0") -from gi.repository import GIRepository +from gi.repository import GIRepository # noqa: E402 def test_generic_new_endpoint_generation(gst_schema): """ Test that structs without constructors get a generic 'new' endpoint. - + Uses GstMeta as a test case since it has methods but no constructor. """ schema = gst_schema - + # Find structs with methods but no constructor # GstMeta should be one of them - meta_new_path = '/Gst/Meta/new' - - assert meta_new_path in schema['paths'], f"Expected generic new endpoint at {meta_new_path}" - + meta_new_path = "/Gst/Meta/new" + + assert meta_new_path in schema["paths"], f"Expected generic new endpoint at {meta_new_path}" + # Verify the endpoint structure - operation = schema['paths'][meta_new_path]['get'] - assert operation['operationId'] == 'Gst-Meta-new', "Operation ID should match" - assert operation['x-gi-constructor'] == True, "Should be marked as constructor" - + operation = schema["paths"][meta_new_path]["get"] + assert operation["operationId"] == "Gst-Meta-new", "Operation ID should match" + assert operation["x-gi-constructor"], "Should be marked as constructor" + # Verify response structure - assert '200' in operation['responses'], "Should have 200 response" - response_schema = operation['responses']['200']['content']['application/json']['schema'] - assert 'return' in response_schema['properties'], "Should return a pointer" - + assert "200" in operation["responses"], "Should have 200 response" + response_schema = operation["responses"]["200"]["content"]["application/json"]["schema"] + assert "return" in response_schema["properties"], "Should return a pointer" + print("✓ Generic new endpoint generated for GstMeta") def test_generic_free_endpoint_generation(gst_schema): """ Test that structs without free methods get a generic 'free' endpoint. - + Uses GstMeta as a test case. """ schema = gst_schema - + # GstMeta should have a generic free endpoint - meta_free_path = '/Gst/Meta/{self}/free' - - assert meta_free_path in schema['paths'], f"Expected generic free endpoint at {meta_free_path}" - + meta_free_path = "/Gst/Meta/{self}/free" + + assert meta_free_path in schema["paths"], f"Expected generic free endpoint at {meta_free_path}" + # Verify the endpoint structure - operation = schema['paths'][meta_free_path]['get'] - assert operation['operationId'] == 'Gst-Meta-free', "Operation ID should match" - + operation = schema["paths"][meta_free_path]["get"] + assert operation["operationId"] == "Gst-Meta-free", "Operation ID should match" + # Verify parameters - assert len(operation['parameters']) == 1, "Should have one parameter" - assert operation['parameters'][0]['name'] == 'self', "Parameter should be 'self'" - assert operation['parameters'][0]['in'] == 'path', "Parameter should be in path" - assert operation['parameters'][0]['required'] == True, "Parameter should be required" - + assert len(operation["parameters"]) == 1, "Should have one parameter" + assert operation["parameters"][0]["name"] == "self", "Parameter should be 'self'" + assert operation["parameters"][0]["in"] == "path", "Parameter should be in path" + assert operation["parameters"][0]["required"], "Parameter should be required" + # Verify response - assert '204' in operation['responses'], "Should have 204 No Content response" - + assert "204" in operation["responses"], "Should have 204 No Content response" + print("✓ Generic free endpoint generated for GstMeta") def test_no_generic_endpoints_for_structs_with_constructors(gst_schema): """ Test that structs with existing constructors don't get generic endpoints. - + Uses GstBuffer as a test case since it has a 'new' constructor. """ schema = gst_schema - + # GstBuffer has actual constructors, so it should NOT have generic ones # Check that the new endpoint exists but is NOT generic - buffer_new_paths = [p for p in schema['paths'] if '/Buffer/new' in p] - + buffer_new_paths = [p for p in schema["paths"] if "/Buffer/new" in p] + assert len(buffer_new_paths) > 0, "GstBuffer should have constructor endpoints" - + print("✓ GstBuffer has real constructors") def test_gobject_value_generic_endpoints(gobject_schema): """ Test that GObject.Value gets generic new/free endpoints. - + GValue is specifically mentioned in the issue as an example. """ schema = gobject_schema - + # GObject.Value should have generic new/free endpoints - value_new_path = '/GObject/Value/new' - value_free_path = '/GObject/Value/{self}/free' - - assert value_new_path in schema['paths'], f"Expected generic new endpoint for GValue" - assert value_free_path in schema['paths'], f"Expected generic free endpoint for GValue" - + value_new_path = "/GObject/Value/new" + value_free_path = "/GObject/Value/{self}/free" + + assert value_new_path in schema["paths"], "Expected generic new endpoint for GValue" + assert value_free_path in schema["paths"], "Expected generic free endpoint for GValue" + # Verify the new endpoint exists - new_operation = schema['paths'][value_new_path]['get'] - assert new_operation['x-gi-constructor'] == True, "GValue new should be marked as constructor" - + new_operation = schema["paths"][value_new_path]["get"] + assert new_operation["x-gi-constructor"], "GValue new should be marked as constructor" + print("✓ GObject.Value has generic new/free endpoints") @@ -114,24 +115,23 @@ def test_multiple_structs_with_generic_endpoints(gst_schema): Test that multiple structs get generic endpoints as expected. """ schema = gst_schema - + # Find all new endpoints marked as constructors (which includes generic ones) # Generic constructors can be identified by checking if they're in structs constructor_endpoints = [] - for path, operations in schema['paths'].items(): + for path, operations in schema["paths"].items(): for method, operation in operations.items(): - if operation.get('x-gi-constructor'): - op_id = operation['operationId'] + if operation.get("x-gi-constructor"): + op_id = operation["operationId"] # Generic constructors follow pattern: namespace-structname-new - if op_id.endswith('-new'): + if op_id.endswith("-new"): constructor_endpoints.append(op_id) - + # Should have multiple constructors - assert len(constructor_endpoints) >= 5, \ - f"Expected at least 5 constructors, found {len(constructor_endpoints)}" - + assert len(constructor_endpoints) >= 5, f"Expected at least 5 constructors, found {len(constructor_endpoints)}" + print(f"✓ Found {len(constructor_endpoints)} structs with constructors") - + # Print some examples for op_id in constructor_endpoints[:5]: print(f" - {op_id}") @@ -142,19 +142,19 @@ def test_resolver_identifies_generic_new_operation(gst_girest): Test that the resolver correctly identifies generic 'new' operations. """ girest = gst_girest - + # We can't fully test the resolver without a Frida connection, # but we can test the pattern matching logic - + # Operation ID for generic new: namespace-structname-new operation_id = "Gst-Meta-new" - parts = operation_id.split('-') - + parts = operation_id.split("-") + assert len(parts) == 3, "Operation ID should have 3 parts" assert parts[0] == "Gst", "First part should be namespace" assert parts[1] == "Meta", "Second part should be struct name" assert parts[2] == "new", "Third part should be 'new'" - + # Check if Meta exists and is a struct n_infos = girest.repo.get_n_infos("Gst") struct_found = False @@ -165,7 +165,7 @@ def test_resolver_identifies_generic_new_operation(gst_girest): # Check it has methods but no constructor n_methods = GIRepository.struct_info_get_n_methods(info) assert n_methods > 0, "Meta should have methods" - + has_constructor = False for j in range(n_methods): method = GIRepository.struct_info_get_method(info, j) @@ -173,10 +173,10 @@ def test_resolver_identifies_generic_new_operation(gst_girest): if bool(flags & GIRepository.FunctionInfoFlags.IS_CONSTRUCTOR): has_constructor = True break - + assert not has_constructor, "Meta should not have a real constructor" break - + assert struct_found, "Meta struct should exist" print("✓ Resolver can identify generic 'new' operations") @@ -186,16 +186,16 @@ def test_resolver_identifies_generic_free_operation(gst_girest): Test that the resolver correctly identifies generic 'free' operations. """ girest = gst_girest - + # Operation ID for generic free: namespace-structname-free operation_id = "Gst-Meta-free" - parts = operation_id.split('-') - + parts = operation_id.split("-") + assert len(parts) == 3, "Operation ID should have 3 parts" assert parts[0] == "Gst", "First part should be namespace" assert parts[1] == "Meta", "Second part should be struct name" assert parts[2] == "free", "Third part should be 'free'" - + # Check if Meta exists and doesn't have a free method n_infos = girest.repo.get_n_infos("Gst") struct_found = False @@ -204,17 +204,17 @@ def test_resolver_identifies_generic_free_operation(gst_girest): if info.get_type() == GIRepository.InfoType.STRUCT and info.get_name() == "Meta": struct_found = True n_methods = GIRepository.struct_info_get_n_methods(info) - + has_free = False for j in range(n_methods): method = GIRepository.struct_info_get_method(info, j) if method.get_name() == "free": has_free = True break - + assert not has_free, "Meta should not have a real free method" break - + assert struct_found, "Meta struct should exist" print("✓ Resolver can identify generic 'free' operations") @@ -224,47 +224,47 @@ def test_generic_endpoint_exists(gobject_schema): Test that generic endpoints are created for structs without constructors. """ schema = gobject_schema - + # GObject.Value should have generic new endpoint - value_new_path = '/GObject/Value/new' - assert value_new_path in schema['paths'], "Value new endpoint should exist" - - operation = schema['paths'][value_new_path]['get'] - assert operation.get('x-gi-constructor') == True, "Should be marked as constructor" - + value_new_path = "/GObject/Value/new" + assert value_new_path in schema["paths"], "Value new endpoint should exist" + + operation = schema["paths"][value_new_path]["get"] + assert operation.get("x-gi-constructor"), "Should be marked as constructor" + print("✓ Generic endpoints are created for structs without constructors") def test_struct_vs_boxed_type_detection(gst_schema): """ Test that the schema correctly identifies struct types and their GType registration. - + This verifies: - GstIterator is marked as a struct - GstMessage is marked as a struct (boxed types are still structs in GIRepository) - The schema metadata is correctly generated """ schema = gst_schema - + # Check if schemas are present assert "components" in schema assert "schemas" in schema["components"] schemas = schema["components"]["schemas"] - + # GstIterator should be present and marked as a struct if "GstIterator" in schemas: iterator_schema = schemas["GstIterator"] # It should be marked as a struct assert iterator_schema.get("x-gi-type") == "struct", "GstIterator should be marked as struct" print("✓ GstIterator correctly identified as struct") - + # GstMessage should be present and marked as a struct # (boxed types are still structs in GIRepository) if "GstMessage" in schemas: message_schema = schemas["GstMessage"] assert message_schema.get("x-gi-type") == "struct", "GstMessage should be marked as struct" print("✓ GstMessage correctly identified as struct") - + print("✓ Schema correctly identifies struct types") @@ -273,21 +273,21 @@ def test_generic_endpoints_have_correct_tags(gst_schema): Test that generic endpoints have the correct tags for TypeScript generation. """ schema = gst_schema - + # Check Meta endpoints - meta_new_path = '/Gst/Meta/new' - meta_free_path = '/Gst/Meta/{self}/free' - + meta_new_path = "/Gst/Meta/new" + meta_free_path = "/Gst/Meta/{self}/free" + # Both should have the same tag (class name) - new_operation = schema['paths'][meta_new_path]['get'] - free_operation = schema['paths'][meta_free_path]['get'] - - assert 'tags' in new_operation, "new endpoint should have tags" - assert 'tags' in free_operation, "free endpoint should have tags" - - assert new_operation['tags'] == ['GstMeta'], "new endpoint should have GstMeta tag" - assert free_operation['tags'] == ['GstMeta'], "free endpoint should have GstMeta tag" - + new_operation = schema["paths"][meta_new_path]["get"] + free_operation = schema["paths"][meta_free_path]["get"] + + assert "tags" in new_operation, "new endpoint should have tags" + assert "tags" in free_operation, "free endpoint should have tags" + + assert new_operation["tags"] == ["GstMeta"], "new endpoint should have GstMeta tag" + assert free_operation["tags"] == ["GstMeta"], "free endpoint should have GstMeta tag" + print("✓ Generic endpoints have correct tags for TypeScript class generation") @@ -296,99 +296,98 @@ def test_operation_ids_are_consistent(gst_schema): Test that operation IDs follow the expected pattern. """ schema = gst_schema - + # Find all struct constructor/destructor operations # These follow the pattern: namespace-structname-{new|free} struct_operations = [] - for path, operations in schema['paths'].items(): + for path, operations in schema["paths"].items(): for method, operation in operations.items(): - op_id = operation['operationId'] - parts = op_id.split('-') + op_id = operation["operationId"] + parts = op_id.split("-") # Struct operations have 3 parts and end with 'new' or 'free' - if len(parts) == 3 and parts[2] in ['new', 'free']: - struct_operations.append({ - 'path': path, - 'operation_id': op_id, - 'is_constructor': operation.get('x-gi-constructor', False) - }) - + if len(parts) == 3 and parts[2] in ["new", "free"]: + struct_operations.append( + {"path": path, "operation_id": op_id, "is_constructor": operation.get("x-gi-constructor", False)} + ) + # Check that operation IDs follow the pattern for op in struct_operations: - op_id = op['operation_id'] - parts = op_id.split('-') - + op_id = op["operation_id"] + parts = op_id.split("-") + assert len(parts) == 3, f"Operation ID {op_id} should have 3 parts" - + # Should end with 'new' or 'free' - assert parts[2] in ['new', 'free'], \ - f"Operation ID {op_id} should end with 'new' or 'free'" - + assert parts[2] in ["new", "free"], f"Operation ID {op_id} should end with 'new' or 'free'" + # If it's a constructor, should end with 'new' - if op['is_constructor']: - assert parts[2] == 'new', \ - f"Constructor operation {op_id} should end with 'new'" - + if op["is_constructor"]: + assert parts[2] == "new", f"Constructor operation {op_id} should end with 'new'" + print(f"✓ All {len(struct_operations)} struct operations have consistent IDs") def test_struct_methods_in_schema(gst_schema): """ Test that struct methods are generated in the OpenAPI schema. - + Uses GstBuffer as a test case since it's a struct with many methods. """ schema = gst_schema - + # Verify GstBuffer schema exists - assert 'GstBuffer' in schema['components']['schemas'], "GstBuffer schema should exist" - + assert "GstBuffer" in schema["components"]["schemas"], "GstBuffer schema should exist" + # Verify it's marked as a struct - buffer_schema = schema['components']['schemas']['GstBuffer'] - assert buffer_schema.get('x-gi-type') == 'struct', "GstBuffer should be marked as struct" - + buffer_schema = schema["components"]["schemas"]["GstBuffer"] + assert buffer_schema.get("x-gi-type") == "struct", "GstBuffer should be marked as struct" + # Find paths with /Buffer/ in them (struct methods) - buffer_paths = [p for p in schema['paths'] if '/Buffer/' in p] - + buffer_paths = [p for p in schema["paths"] if "/Buffer/" in p] + # GstBuffer should have methods (it has 58+ methods) assert len(buffer_paths) > 0, f"Expected Buffer methods, found {len(buffer_paths)}" assert len(buffer_paths) >= 50, f"Expected at least 50 Buffer methods, found {len(buffer_paths)}" - + # Check that at least one constructor exists - constructor_paths = [p for p in buffer_paths if '/Buffer/new' in p] + constructor_paths = [p for p in buffer_paths if "/Buffer/new" in p] assert len(constructor_paths) > 0, "Expected at least one constructor for Buffer" - + # Verify operation structure for one method sample_path = buffer_paths[0] - method = list(schema['paths'][sample_path].keys())[0] - operation = schema['paths'][sample_path][method] - - assert 'operationId' in operation, "Operation should have operationId" - assert 'tags' in operation, "Operation should have tags" - assert operation['tags'] == ['GstBuffer'], f"Expected tag GstBuffer, got {operation['tags']}" - + method = list(schema["paths"][sample_path].keys())[0] + operation = schema["paths"][sample_path][method] + + assert "operationId" in operation, "Operation should have operationId" + assert "tags" in operation, "Operation should have tags" + assert operation["tags"] == ["GstBuffer"], f"Expected tag GstBuffer, got {operation['tags']}" + print(f"✓ Found {len(buffer_paths)} methods for GstBuffer struct") def test_struct_without_methods_in_schema(gst_schema): """ - Test that structs without methods are still generated in the schema. - - Uses GstAllocatorPrivate as a test case since it's a struct without methods. + Test that structs without user-defined methods still get constructor/destructor methods. + + Uses GstAllocatorPrivate as a test case since it's a struct without user-defined methods. + Even though it has no methods in the GIR, it should still have 'new' (constructor) and 'free' (destructor). """ schema = gst_schema - + # Verify GstAllocatorPrivate schema exists - assert 'GstAllocatorPrivate' in schema['components']['schemas'], \ - "GstAllocatorPrivate schema should exist" - + assert "GstAllocatorPrivate" in schema["components"]["schemas"], "GstAllocatorPrivate schema should exist" + # Verify it's marked as a struct - private_schema = schema['components']['schemas']['GstAllocatorPrivate'] - assert private_schema.get('x-gi-type') == 'struct', \ - "GstAllocatorPrivate should be marked as struct" - - # Should not have any methods (paths) - private_paths = [p for p in schema['paths'] if '/AllocatorPrivate/' in p] - assert len(private_paths) == 0, \ - f"GstAllocatorPrivate should have no methods, found {len(private_paths)}" - - print("✓ Struct without methods has schema but no endpoints") + private_schema = schema["components"]["schemas"]["GstAllocatorPrivate"] + assert private_schema.get("x-gi-type") == "struct", "GstAllocatorPrivate should be marked as struct" + + # Should have constructor and destructor methods (new and free) + private_paths = [p for p in schema["paths"] if "/AllocatorPrivate/" in p] + assert len(private_paths) == 2, f"GstAllocatorPrivate should have 2 methods (new/free), found {len(private_paths)}" + + # Verify the specific methods + path_names = [p.split("/")[-1] for p in private_paths] + assert "new" in path_names, "GstAllocatorPrivate should have 'new' constructor method" + assert any("free" in p for p in private_paths), "GstAllocatorPrivate should have 'free' destructor method" + + print("✓ Struct without user-defined methods has schema with constructor/destructor endpoints") diff --git a/girest/tests/test_uri_parser.py b/girest/tests/test_uri_parser.py index d1e7bcc..b281f23 100644 --- a/girest/tests/test_uri_parser.py +++ b/girest/tests/test_uri_parser.py @@ -8,7 +8,6 @@ - Integration with the existing schema works correctly """ -import pytest from girest.uri_parser import URITemplateParser from girest.validators import GIRestParameterValidator @@ -28,9 +27,9 @@ def test_initialization(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + assert parser is not None assert "test_param" in parser._param_defns @@ -45,13 +44,13 @@ def test_simple_query_parameter(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Test resolving a simple query parameter params = {"name": ["test_value"]} resolved = parser.resolve_query(params) - + assert "name" in resolved assert resolved["name"] == "test_value" @@ -67,13 +66,13 @@ def test_array_parameter_with_explode(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Test with multiple values (exploded) params = {"ids": ["1", "2", "3"]} resolved = parser.resolve_query(params) - + assert "ids" in resolved assert isinstance(resolved["ids"], list) assert len(resolved["ids"]) == 3 @@ -96,13 +95,13 @@ def test_object_parameter(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Test with an object value params = {"filter": [{"name": "John", "age": 30}]} resolved = parser.resolve_query(params) - + assert "filter" in resolved # Object should be preserved assert isinstance(resolved["filter"], dict) @@ -118,13 +117,13 @@ def test_path_parameter(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Test with a path parameter params = {"id": "123"} resolved = parser.resolve_path(params) - + assert "id" in resolved assert resolved["id"] == 123 # Should be coerced to integer @@ -145,13 +144,13 @@ def test_allof_schema_parameter(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Test with a value that should match both schemas params = {"obj": [{"a": "test", "b": 42}]} resolved = parser.resolve_query(params) - + assert "obj" in resolved @@ -165,17 +164,13 @@ def test_simple_parameter_validation(self): "schema": {"type": "string"}, "required": True, } - + # Valid value - error = GIRestParameterValidator.validate_parameter( - "query", "test_value", param - ) + error = GIRestParameterValidator.validate_parameter("query", "test_value", param) assert error is None - + # Invalid type - error = GIRestParameterValidator.validate_parameter( - "query", 123, param - ) + error = GIRestParameterValidator.validate_parameter("query", 123, param) assert error is not None def test_allof_validation(self): @@ -190,17 +185,13 @@ def test_allof_validation(self): }, "required": True, } - + # Valid object matching both schemas - error = GIRestParameterValidator.validate_parameter( - "query", {"a": "test", "b": 42}, param - ) + error = GIRestParameterValidator.validate_parameter("query", {"a": "test", "b": 42}, param) assert error is None - + # Invalid - missing required property - error = GIRestParameterValidator.validate_parameter( - "query", {"a": "test"}, param - ) + error = GIRestParameterValidator.validate_parameter("query", {"a": "test"}, param) # Should fail because b is missing (depending on schema strictness) # For now, just check it doesn't crash @@ -216,23 +207,17 @@ def test_anyof_validation(self): }, "required": True, } - + # Valid string - error = GIRestParameterValidator.validate_parameter( - "query", "test", param - ) + error = GIRestParameterValidator.validate_parameter("query", "test", param) assert error is None - + # Valid integer - error = GIRestParameterValidator.validate_parameter( - "query", 123, param - ) + error = GIRestParameterValidator.validate_parameter("query", 123, param) assert error is None - + # Invalid - doesn't match any schema - error = GIRestParameterValidator.validate_parameter( - "query", {"obj": "value"}, param - ) + error = GIRestParameterValidator.validate_parameter("query", {"obj": "value"}, param) assert error is not None def test_oneof_validation(self): @@ -247,17 +232,13 @@ def test_oneof_validation(self): }, "required": True, } - + # Valid string - error = GIRestParameterValidator.validate_parameter( - "query", "test", param - ) + error = GIRestParameterValidator.validate_parameter("query", "test", param) assert error is None - + # Valid integer - error = GIRestParameterValidator.validate_parameter( - "query", 42, param - ) + error = GIRestParameterValidator.validate_parameter("query", 42, param) assert error is None def test_nullable_parameter(self): @@ -267,11 +248,9 @@ def test_nullable_parameter(self): "schema": {"type": "string", "nullable": True}, "required": False, } - + # Null value should be accepted - error = GIRestParameterValidator.validate_parameter( - "query", None, param - ) + error = GIRestParameterValidator.validate_parameter("query", None, param) assert error is None def test_required_parameter_missing(self): @@ -281,11 +260,9 @@ def test_required_parameter_missing(self): "schema": {"type": "string"}, "required": True, } - + # None value for required parameter should fail - error = GIRestParameterValidator.validate_parameter( - "query", None, param - ) + error = GIRestParameterValidator.validate_parameter("query", None, param) assert error is not None assert "Missing" in error or "required" in error.lower() @@ -310,122 +287,98 @@ def test_parser_and_validator_together(self): } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Parse the parameter params = {"obj": [{"ptr": 12345}]} resolved = parser.resolve_query(params) - + # Validate the parsed parameter - error = GIRestParameterValidator.validate_parameter( - "query", resolved["obj"], param_defns[0] - ) - + error = GIRestParameterValidator.validate_parameter("query", resolved["obj"], param_defns[0]) + # Should be valid assert error is None class TestPointerParsing: """Test cases for pointer parameter parsing.""" - + def test_pointer_with_hex_prefix(self): """Test parsing a pointer parameter with 0x prefix.""" param_defns = [ { "name": "ptr_param", "in": "query", - "schema": { - "oneOf": [ - {"type": "integer"}, - {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"} - ] - }, + "schema": {"oneOf": [{"type": "integer"}, {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"}]}, "style": "form", "explode": False, } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Parse with hex prefix params = {"ptr_param": ["0x12345abc"]} resolved = parser.resolve_query(params) - + # Validate it's parsed correctly assert "ptr_param" in resolved assert resolved["ptr_param"] == "0x12345abc" - + # Validate against the schema - error = GIRestParameterValidator.validate_parameter( - "query", resolved["ptr_param"], param_defns[0] - ) + error = GIRestParameterValidator.validate_parameter("query", resolved["ptr_param"], param_defns[0]) assert error is None, f"Expected valid, got error: {error}" - + def test_pointer_with_integer_value(self): """Test parsing a pointer parameter as an integer.""" param_defns = [ { "name": "ptr_param", "in": "query", - "schema": { - "oneOf": [ - {"type": "integer"}, - {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"} - ] - }, + "schema": {"oneOf": [{"type": "integer"}, {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"}]}, "style": "form", "explode": False, } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Parse with integer value (as string from URL) params = {"ptr_param": ["12345"]} resolved = parser.resolve_query(params) - + # Validate it's parsed correctly assert "ptr_param" in resolved assert resolved["ptr_param"] == "12345" - + # Validate against the schema - error = GIRestParameterValidator.validate_parameter( - "query", resolved["ptr_param"], param_defns[0] - ) + error = GIRestParameterValidator.validate_parameter("query", resolved["ptr_param"], param_defns[0]) assert error is None, f"Expected valid, got error: {error}" - + def test_pointer_as_direct_integer(self): """Test parsing a pointer parameter passed as an integer (not string).""" param_defns = [ { "name": "ptr_param", "in": "path", - "schema": { - "oneOf": [ - {"type": "integer"}, - {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"} - ] - }, + "schema": {"oneOf": [{"type": "integer"}, {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"}]}, } ] body_defn = {} - + parser = URITemplateParser(param_defns, body_defn) - + # Parse with integer value (actual integer, not string) params = {"ptr_param": 305419896} # 0x12345678 in decimal resolved = parser.resolve_path(params) - + # Validate it's parsed correctly assert "ptr_param" in resolved assert resolved["ptr_param"] == 305419896 - + # Validate against the schema - error = GIRestParameterValidator.validate_parameter( - "path", resolved["ptr_param"], param_defns[0] - ) + error = GIRestParameterValidator.validate_parameter("path", resolved["ptr_param"], param_defns[0]) assert error is None, f"Expected valid, got error: {error}" - diff --git a/girest/tests/test_url_objects.py b/girest/tests/test_url_objects.py index b30b76d..6d95c58 100644 --- a/girest/tests/test_url_objects.py +++ b/girest/tests/test_url_objects.py @@ -7,6 +7,7 @@ """ import pytest + from girest.main import GIRest from girest.uri_parser import URITemplateParser from girest.validators import GIRestParameterValidator @@ -15,26 +16,26 @@ def test_object_in_path_parameter(): """Test that path parameters with object schemas are handled correctly.""" # Generate a real schema - girest = GIRest('Gst', '1.0') + girest = GIRest("Gst", "1.0") spec = girest.generate() spec_dict = spec.to_dict() - + # Find an endpoint with an object in the path - path = '/Gst/AllocationParams/{self}/copy' - operation = spec_dict['paths'][path]['get'] - + path = "/Gst/AllocationParams/{self}/copy" + operation = spec_dict["paths"][path]["get"] + # Get the parameters - params = operation['parameters'] - + params = operation["parameters"] + # Create a URI parser with these parameters parser = URITemplateParser(params, {}) - + # Test parsing a path parameter with an object value # According to OpenAPI spec with style=simple, explode=false (default for path), # an object {"ptr": "0x12345"} should be serialized as "ptr,0x12345" path_params = {"self": "ptr,0x12345"} resolved = parser.resolve_path(path_params) - + assert "self" in resolved # The value should be parsed correctly as a dict with ptr assert resolved["self"] == {"ptr": "0x12345"}, f"Expected {{'ptr': '0x12345'}}, got {resolved['self']}" @@ -43,31 +44,31 @@ def test_object_in_path_parameter(): def test_allof_schema_in_path(): """Test that path parameters with allOf schemas are handled correctly.""" # Generate a real schema - girest = GIRest('Gst', '1.0') + girest = GIRest("Gst", "1.0") spec = girest.generate() spec_dict = spec.to_dict() - + # Find an endpoint with an object that has allOf (inheritance) # Look for a GObject method since GObject has inheritance path = None - for p, path_item in spec_dict['paths'].items(): - if 'GObject/Binding' in p and '{self}' in p: + for p, path_item in spec_dict["paths"].items(): + if "GObject/Binding" in p and "{self}" in p: path = p break - + if path is None: pytest.skip("Could not find a suitable endpoint with allOf schema") - - operation = spec_dict['paths'][path]['get'] - params = operation['parameters'] - + + operation = spec_dict["paths"][path]["get"] + params = operation["parameters"] + # Create a URI parser with these parameters parser = URITemplateParser(params, {}) - + # Test parsing with a pointer value in object serialization format path_params = {"self": "ptr,12345"} # Serialized object with ptr property resolved = parser.resolve_path(path_params) - + assert "self" in resolved assert resolved["self"] == {"ptr": "12345"}, f"Expected {{'ptr': '12345'}}, got {resolved['self']}" @@ -79,36 +80,23 @@ def test_validator_handles_pointer_schema(): "name": "self", "in": "path", "required": True, - "schema": { - "oneOf": [ - {"type": "integer"}, - {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"} - ] - } + "schema": {"oneOf": [{"type": "integer"}, {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"}]}, } - + # Test with integer pointer - error = GIRestParameterValidator.validate_parameter( - "path", 12345, param - ) + error = GIRestParameterValidator.validate_parameter("path", 12345, param) assert error is None, f"Integer pointer should be valid, got error: {error}" - + # Test with hex string pointer - error = GIRestParameterValidator.validate_parameter( - "path", "0x12345", param - ) + error = GIRestParameterValidator.validate_parameter("path", "0x12345", param) assert error is None, f"Hex string pointer should be valid, got error: {error}" - + # Test with decimal string pointer - error = GIRestParameterValidator.validate_parameter( - "path", "12345", param - ) + error = GIRestParameterValidator.validate_parameter("path", "12345", param) assert error is None, f"Decimal string pointer should be valid, got error: {error}" - + # Test with invalid pointer - error = GIRestParameterValidator.validate_parameter( - "path", "invalid", param - ) + error = GIRestParameterValidator.validate_parameter("path", "invalid", param) assert error is not None, "Invalid pointer should fail validation" @@ -121,27 +109,18 @@ def test_struct_schema_validation(): "schema": { "type": "object", "properties": { - "ptr": { - "oneOf": [ - {"type": "integer"}, - {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"} - ] - } + "ptr": {"oneOf": [{"type": "integer"}, {"type": "string", "pattern": "^0x[0-9a-fA-F]+$|^[0-9]+$"}]} }, - "required": ["ptr"] - } + "required": ["ptr"], + }, } - + # Test with valid object - error = GIRestParameterValidator.validate_parameter( - "path", {"ptr": 12345}, param - ) + error = GIRestParameterValidator.validate_parameter("path", {"ptr": 12345}, param) assert error is None, f"Valid struct object should pass, got error: {error}" - + # Test with hex pointer - error = GIRestParameterValidator.validate_parameter( - "path", {"ptr": "0x12345"}, param - ) + error = GIRestParameterValidator.validate_parameter("path", {"ptr": "0x12345"}, param) assert error is None, f"Struct with hex pointer should pass, got error: {error}" @@ -153,28 +132,18 @@ def test_object_with_allof_validation(): "required": True, "schema": { "allOf": [ - { - "type": "object", - "properties": {"ptr": {"type": "integer"}}, - "required": ["ptr"] - }, - { - "type": "object" - } + {"type": "object", "properties": {"ptr": {"type": "integer"}}, "required": ["ptr"]}, + {"type": "object"}, ] - } + }, } - + # Test with valid object - error = GIRestParameterValidator.validate_parameter( - "query", {"ptr": 12345}, param - ) + error = GIRestParameterValidator.validate_parameter("query", {"ptr": 12345}, param) assert error is None, f"Object matching allOf should pass, got error: {error}" - + # Test with missing required property - error = GIRestParameterValidator.validate_parameter( - "query", {}, param - ) + error = GIRestParameterValidator.validate_parameter("query", {}, param) assert error is not None, "Object missing required property should fail" @@ -184,27 +153,21 @@ def test_form_style_explode_query_params(): { "name": "filter", "in": "query", - "schema": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "value": {"type": "integer"} - } - }, + "schema": {"type": "object", "properties": {"name": {"type": "string"}, "value": {"type": "integer"}}}, "style": "form", "explode": True, - "required": False + "required": False, } ] - + parser = URITemplateParser(params, {}) - + # Test with an object parameter # In form style with explode, this would typically be passed as name=test&value=42 # But we're testing the parser handles object values query_params = {"filter": [{"name": "test", "value": 42}]} resolved = parser.resolve_query(query_params) - + assert "filter" in resolved assert isinstance(resolved["filter"], dict) @@ -212,25 +175,22 @@ def test_form_style_explode_query_params(): def test_integration_with_real_endpoint(): """Integration test using a real GIRest endpoint schema.""" # Generate the schema - girest = GIRest('Gst', '1.0') + girest = GIRest("Gst", "1.0") spec = girest.generate() spec_dict = spec.to_dict() - + # Get a method endpoint - path = '/Gst/AllocationParams/{self}/copy' - operation = spec_dict['paths'][path]['get'] - params = operation['parameters'] - + path = "/Gst/AllocationParams/{self}/copy" + operation = spec_dict["paths"][path]["get"] + params = operation["parameters"] + # Create parser parser = URITemplateParser(params, {}) - + # Parse path with object in serialized format (style=simple, explode=false) path_data = {"self": "ptr,0x7fff12345678"} resolved = parser.resolve_path(path_data) - - # Validate the parameter - self_param = [p for p in params if p['name'] == 'self'][0] - + # The validator should accept the resolved value # The resolved value should be an object with ptr property assert "self" in resolved diff --git a/gstaudit-server/gstaudit_server/app.py b/gstaudit-server/gstaudit_server/app.py index b1c0261..b14c689 100644 --- a/gstaudit-server/gstaudit_server/app.py +++ b/gstaudit-server/gstaudit_server/app.py @@ -1,68 +1,56 @@ #!/usr/bin/env python3 +import argparse +import ctypes import logging import os -import argparse -import threading -import subprocess import shlex -import ctypes -import signal +import subprocess +import threading import time from apispec import APISpec - -from girest.uri_parser import URITemplateParser +from connexion.resolver import Resolver from girest.app import GIApp from girest.resolvers import FridaResolver -from connexion import AsyncApp - +from starlette.middleware.cors import CORSMiddleware from uvicorn.config import LOGGING_CONFIG from uvicorn.logging import DefaultFormatter -from connexion.datastructures import MediaTypeDict -from connexion.resolver import Resolver -from starlette.middleware.cors import CORSMiddleware def create_stub(): """ Fork a child process with GStreamer libraries pre-loaded. - + The child process will: 1. Load the GStreamer libraries 2. Keep running to allow Frida to attach - + Returns: PID of the child process (from parent), or does not return (from child) """ # GStreamer libraries to load - lib_paths = [ - "libgstreamer-1.0.so.0", - "libgobject-2.0.so.0", - "libglib-2.0.so.0" - ] - + lib_paths = ["libgstreamer-1.0.so.0", "libgobject-2.0.so.0", "libglib-2.0.so.0"] + pid = os.fork() - + if pid > 0: # PARENT: Return the PID to the caller return pid else: - # CHILD: This is the process Frida will attach to - child_pid = os.getpid() - # 1. Map the libraries into memory for lib in lib_paths: try: ctypes.CDLL(lib) - except OSError as e: + except OSError: # Silently continue if library fails to load pass - + # 2. Keep the process alive for Frida to attach # The process needs to be running (not stopped) for Frida to attach successfully while True: time.sleep(1) + class GstAuditResolver(Resolver): def resolve_function_from_operation_id(self, operation_id): async def get_pipelines(*args, **kwargs): @@ -72,12 +60,13 @@ async def get_pipelines(*args, **kwargs): return get_pipelines + def _add_pipeline(pipeline_data: dict): """ Add a pipeline to the list of discovered pipelines. - + This is thread-safe and can be called from any thread (e.g., Frida's message handler). - + Args: pipeline_data: Dictionary containing pipeline data (ptr, name, etc.) """ @@ -87,10 +76,11 @@ def _add_pipeline(pipeline_data: dict): if ptr and not any(p.get("ptr") == ptr for p in pipelines): pipelines.append(pipeline_data) + def _get_pipelines() -> list: """ Get the current list of discovered pipelines. - + Returns: List of pipeline dictionaries """ @@ -98,6 +88,7 @@ def _get_pipelines() -> list: # Return a copy to avoid external modifications return list(pipelines) + def _on_log(level, message): """Handle the console from js""" levels = { @@ -108,13 +99,14 @@ def _on_log(level, message): } logger.log(levels[level], message) + def _on_message(message, data): """Handle messages from the Frida script""" if message["type"] != "send": return payload = message.get("payload", {}) kind = payload.get("kind") - + # Handle pipeline discovery messages if kind == "pipeline": _add_pipeline(payload["data"]) @@ -122,55 +114,29 @@ def _on_message(message, data): # For now, just log other messages logger.debug(f"Message from Frida: {message}") + # Parsing of arguments -parser = argparse.ArgumentParser( - description="GstAudit server - Instrument and audit GStreamer pipelines" -) +parser = argparse.ArgumentParser(description="GstAudit server - Instrument and audit GStreamer pipelines") # Common arguments shared by all subcommands -parser.add_argument( - "--host", - type=str, - default="localhost", - help="Host to bind the server to (default: localhost)" -) -parser.add_argument( - "--port", - type=int, - default=9000, - help="Port to run the server on (default: 9000)" -) +parser.add_argument("--host", type=str, default="localhost", help="Host to bind the server to (default: localhost)") +parser.add_argument("--port", type=int, default=9000, help="Port to run the server on (default: 9000)") # Create subparsers for the three modes subparsers = parser.add_subparsers(dest="mode", required=True, help="Operation mode") # 1. Connect mode - attach to an existing process -connect_parser = subparsers.add_parser( - "connect", - help="Connect to an existing GStreamer process by PID" -) -connect_parser.add_argument( - "pid", - type=int, - help="Process ID to instrument" -) +connect_parser = subparsers.add_parser("connect", help="Connect to an existing GStreamer process by PID") +connect_parser.add_argument("pid", type=int, help="Process ID to instrument") # 2. Create mode - create a new empty process (for manual pipeline creation) create_parser = subparsers.add_parser( - "create", - help="Create a new GStreamer process (for manual pipeline creation via API)" + "create", help="Create a new GStreamer process (for manual pipeline creation via API)" ) # 3. Launch mode - launch a pipeline using gst-launch syntax -launch_parser = subparsers.add_parser( - "launch", - help="Launch a GStreamer pipeline using gst-launch-1.0 syntax" -) -launch_parser.add_argument( - "pipeline", - type=str, - help="GStreamer pipeline description (gst-launch-1.0 syntax)" -) +launch_parser = subparsers.add_parser("launch", help="Launch a GStreamer pipeline using gst-launch-1.0 syntax") +launch_parser.add_argument("pipeline", type=str, help="GStreamer pipeline description (gst-launch-1.0 syntax)") args = parser.parse_args() @@ -211,14 +177,15 @@ def _on_message(message, data): logger.info(f"Launched pipeline '{args.pipeline}' with PID: {pid}") # Create the resolver with Frida -script_path = os.path.join((os.path.dirname(__file__)), 'script.js') +script_path = os.path.join((os.path.dirname(__file__)), "script.js") resolver = FridaResolver("Gst", "1.0", pid, scripts=[script_path], on_message=_on_message, on_log=_on_log) # Create the girest GIApp app = GIApp(__name__, "Gst", "1.0", resolver, default_base_path="/girest") # Add CORS middleware before exception handling to ensure it handles OPTIONS requests -from connexion.middleware import MiddlewarePosition +from connexion.middleware import MiddlewarePosition # noqa: E402 + app.add_middleware( CORSMiddleware, position=MiddlewarePosition.BEFORE_EXCEPTION, @@ -244,18 +211,9 @@ def _on_message(message, data): "responses": { "200": { "description": "Get the GstPipelines available in the process", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "integer" - } - } - } - } + "content": {"application/json": {"schema": {"type": "array", "items": {"type": "integer"}}}}, } - } + }, } gstaudit_spec.path(path="/GstAudit/pipelines", operations={"get": operation}) diff --git a/gstaudit-server/pyproject.toml b/gstaudit-server/pyproject.toml index 7531c00..da8fc11 100644 --- a/gstaudit-server/pyproject.toml +++ b/gstaudit-server/pyproject.toml @@ -8,9 +8,21 @@ include = ["script.js"] [tool.poetry.dependencies] python = "^3.10" -connexion = { version = "^3.3.0", extras = ["flask", "swagger-ui","uvicorn"] } -girest = {path = "../girest", develop = true} +connexion = { version = "^3.3.0", extras = ["flask", "swagger-ui", "uvicorn"] } +girest = { path = "../girest", develop = true } + +[tool.poetry.group.dev.dependencies] +ruff = "*" +pre-commit = "*" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +extend-select = ["FA102", "I"] +ignore = ["E722"] diff --git a/gstaudit/app/logs/page.tsx b/gstaudit/app/logs/page.tsx index 2177ae8..abe82af 100644 --- a/gstaudit/app/logs/page.tsx +++ b/gstaudit/app/logs/page.tsx @@ -56,11 +56,6 @@ export default function LogsPage() { } }, [connection, router]); - // Don't render if not connected - if (!connection) { - return null; - } - // Función para vaciar el buffer y actualizar el estado const flushLogs = useCallback(() => { // 1. Capturamos el contenido actual del buffer (copia síncrona inmediata) @@ -194,6 +189,7 @@ export default function LogsPage() { line: number, object: GObjectObject, message: GstDebugMessage, + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any user_data: any ) => { // Process asynchronously but don't block the callback @@ -244,6 +240,11 @@ export default function LogsPage() { } }, [logs, isLogging]); + // Don't render if not connected (will be redirected by useEffect above) + if (!connection) { + return null; + } + return (
@@ -361,7 +362,7 @@ export default function LogsPage() {

No logs captured.

-

Click "Start" to begin logging.

+

Click "Start" to begin logging.

)}
diff --git a/gstaudit/components/LinkEdge.tsx b/gstaudit/components/LinkEdge.tsx index 480bd70..8dc2d69 100644 --- a/gstaudit/components/LinkEdge.tsx +++ b/gstaudit/components/LinkEdge.tsx @@ -23,9 +23,9 @@ export const LinkEdge: React.FC = ({ const padWidth = theme.pad.width; let adjustedSourceX = sourceX; - let adjustedSourceY = sourceY; + const adjustedSourceY = sourceY; let adjustedTargetX = targetX; - let adjustedTargetY = targetY; + const adjustedTargetY = targetY; let adjustedSourcePosition = sourcePosition; let adjustedTargetPosition = targetPosition; diff --git a/gstaudit/components/PipelineGraph.tsx b/gstaudit/components/PipelineGraph.tsx index 53439ff..571496f 100644 --- a/gstaudit/components/PipelineGraph.tsx +++ b/gstaudit/components/PipelineGraph.tsx @@ -13,12 +13,15 @@ import { ReactFlowProvider, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import { ElementTreeManager, ElementTree, getTheme, getLayoutedElements } from '@/lib'; +import { ElementTreeManager, ElementTree, ElementPad, getTheme, getLayoutedElements } from '@/lib'; import { GstPadDirection } from '@/lib/gst'; import ElementNode from './ElementNode'; import GroupNode from './GroupNode'; import LinkEdge from './LinkEdge'; +// Node data type wrapping ElementTree to satisfy React Flow's Record constraint +type NodeData = ElementTree & Record; + // Define custom node types const nodeTypes = { element: ElementNode, @@ -57,9 +60,9 @@ const PipelineGraphInner: React.FC = ({ treeManager, selecte console.log(`[PIPELINE_GRAPH] Generated ${nodes.length} nodes and ${edges.length} edges in ${totalTime.toFixed(2)}ms`); return { initialNodes: nodes, initialEdges: edges }; - }, [treeManager, theme]); + }, [treeManager]); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [nodes, setNodes, onNodesChange] = useNodesState>(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); // Apply layout when nodes/edges change @@ -75,7 +78,7 @@ const PipelineGraphInner: React.FC = ({ treeManager, selecte const layoutEnd = performance.now(); console.log(`[PIPELINE_GRAPH] Layout applied in ${(layoutEnd - layoutStart).toFixed(2)}ms`); - setNodes(layoutedNodes); + setNodes(layoutedNodes as Node[]); setEdges(initialEdges); }; @@ -141,7 +144,7 @@ const PipelineGraphInner: React.FC = ({ treeManager, selecte /** * Convert ElementTree to a React Flow node (stores ElementTree in data) */ -function convertToNode(tree: ElementTree): Node { +function convertToNode(tree: ElementTree): Node { const isGroup = tree.children.length > 0; const theme = getTheme(); @@ -150,7 +153,7 @@ function convertToNode(tree: ElementTree): Node { type: isGroup ? 'group' : 'element', parentId: tree.parent?.id, position: { x: 0, y: 0 }, // Will be set by layout - data: tree as any, // Store the entire ElementTree + data: tree as NodeData, // Store the entire ElementTree ...(isGroup && { extent: 'parent' as const, expandParent: true, @@ -173,7 +176,7 @@ function discoverEdges(elementTrees: ElementTree[]): Edge[] { // Build a map of pad ptr -> pad info for quick lookup // Include both regular pads and internal pads from ghost pads - const padMap = new Map(); + const padMap = new Map(); elementTrees.forEach(tree => { tree.pads.forEach(pad => { padMap.set(pad.id, { tree, pad }); @@ -186,7 +189,7 @@ function discoverEdges(elementTrees: ElementTree[]): Edge[] { }); // Helper function to check edges for a pad - const checkPadConnections = (tree: ElementTree, pad: any) => { + const checkPadConnections = (tree: ElementTree, pad: ElementPad) => { if (!pad.linkedTo) return; // Find the peer pad info diff --git a/gstaudit/eslint.config.mjs b/gstaudit/eslint.config.mjs index d4182ef..0b75e40 100644 --- a/gstaudit/eslint.config.mjs +++ b/gstaudit/eslint.config.mjs @@ -18,9 +18,16 @@ const eslintConfig = [ "out/**", "build/**", "next-env.d.ts", - "lib/gst.ts", // Generated file, skip linting + "lib/gst.ts", // Generated file, skip linting for now - TODO: fix generator to produce lint-compliant code ], }, + // Allow require() in .js files (CommonJS) + { + files: ["**/*.js"], + rules: { + "@typescript-eslint/no-require-imports": "off", + }, + }, { rules: { "@typescript-eslint/no-namespace": "off", diff --git a/gstaudit/hooks/useCallbackRegistry.ts b/gstaudit/hooks/useCallbackRegistry.ts index c583b70..6372dd0 100644 --- a/gstaudit/hooks/useCallbackRegistry.ts +++ b/gstaudit/hooks/useCallbackRegistry.ts @@ -10,11 +10,13 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { getCallbackHandler } from '@/lib/gst'; +import type { ClientCallbackHandler } from '@/lib/callbacks'; interface CallbackMessage { type: string; callbackId: string; invocationId?: string; // Unique ID for this specific invocation + // eslint-disable-next-line @typescript-eslint/no-explicit-any args: any; } @@ -36,15 +38,17 @@ interface CallbackRegistryReturn { // Shared WebSocket Manager (Singleton per sessionId) // ============================================================================ +interface WebSocketListener { + onConnect?: () => void; + onDisconnect?: () => void; + onError?: (error: Error) => void; +} + interface WebSocketInstance { - ws: WebSocket; + ws: WebSocket | null; refCount: number; isConnected: boolean; - listeners: Set<{ - onConnect?: () => void; - onDisconnect?: () => void; - onError?: (error: Error) => void; - }>; + listeners: Set; reconnectTimeout?: NodeJS.Timeout; } @@ -62,7 +66,7 @@ function getOrCreateWebSocket( console.log(`[WebSocketManager] Creating new WebSocket instance for session: ${sessionId}`); instance = { - ws: null as any, // Will be set in connect() + ws: null, refCount: 0, isConnected: false, listeners: new Set(), @@ -172,7 +176,7 @@ function handleCallbackMessage(message: CallbackMessage, ws: WebSocket) { } // Look up the callback in ClientCallbackHandler using the registration callbackId - const callbackInfo = (handler as any).getCallback?.(message.callbackId); + const callbackInfo = (handler as ClientCallbackHandler).getCallback?.(message.callbackId); if (!callbackInfo) { console.warn(`[WebSocketManager] Callback not found: ${message.callbackId}`); return; @@ -227,7 +231,7 @@ function addListener( function removeListener( sessionId: string, - listener: any + listener: WebSocketListener ) { const instance = wsInstances.get(sessionId); if (instance) { @@ -269,7 +273,7 @@ export function useCallbackRegistry( } = options; const [isConnected, setIsConnected] = useState(false); - const listenerRef = useRef(null); + const listenerRef = useRef(null); const prevAutoConnectRef = useRef(autoConnect); const prevWsUrlRef = useRef(wsUrl); diff --git a/gstaudit/lib/callbacks.ts b/gstaudit/lib/callbacks.ts index 932f8ed..a504e4a 100644 --- a/gstaudit/lib/callbacks.ts +++ b/gstaudit/lib/callbacks.ts @@ -23,14 +23,26 @@ * 6. Result broadcasted to WebSocket clients if needed */ +/** + * Type for callback functions that can accept any number of arguments + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type CallbackFunction = (...args: any[]) => void | Promise; + +/** + * Type for converter functions that transform callback data + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ConverterFunction = (data: any) => Promise | any[]; + /** * Interface for callback handling in non-SSE mode * This matches the ICallbackHandler interface from generated non-SSE bindings */ export interface ICallbackHandler { registerCallback( - callbackFunc: Function, - converterFunc: (data: any) => Promise | any[], + callbackFunc: CallbackFunction, + converterFunc: ConverterFunction, metadata: { methodName: string; paramName: string } ): { callbackUrl: string; callbackId: string }; unregisterCallback(callbackId: string): void; @@ -74,7 +86,7 @@ export class ClientCallbackHandler implements ICallbackHandler { private callbackUrl: string; private sessionId: string; private callbackSecret: string; - private callbacks: Map Promise | any[] }>; + private callbacks: Map; private idCounter: number; constructor(options: { @@ -90,8 +102,8 @@ export class ClientCallbackHandler implements ICallbackHandler { } registerCallback( - callbackFunc: Function, - converterFunc: (data: any) => Promise | any[], + callbackFunc: CallbackFunction, + converterFunc: ConverterFunction, metadata: { methodName: string; paramName: string } ): { callbackUrl: string; callbackId: string } { // Generate unique callback ID @@ -127,11 +139,11 @@ export class ClientCallbackHandler implements ICallbackHandler { * Get a callback registration by ID. * Used by useCallbackRegistry hook to execute callbacks from WebSocket messages. */ - getCallback(callbackId: string): { func: Function; converter: (data: any) => Promise | any[] } | undefined { + getCallback(callbackId: string): { func: CallbackFunction; converter: ConverterFunction } | undefined { return this.callbacks.get(callbackId); } - getAllCallbacks(): Map Promise | any[] }> { + getAllCallbacks(): Map { return new Map(this.callbacks); } @@ -149,8 +161,8 @@ export class ClientCallbackHandler implements ICallbackHandler { // ============================================================================ interface CallbackRegistration { - func: Function; - converter: (data: any) => Promise | any[]; + func: CallbackFunction; + converter: ConverterFunction; metadata: { methodName: string; paramName: string }; } @@ -214,8 +226,8 @@ export class ServerCallbackHandler implements ICallbackHandler { } registerCallback( - callbackFunc: Function, - converterFunc: (data: any) => Promise | any[], + callbackFunc: CallbackFunction, + converterFunc: ConverterFunction, metadata: { methodName: string; paramName: string } ): { callbackUrl: string; callbackId: string } { // Generate unique callback ID @@ -282,6 +294,7 @@ export class ServerCallbackHandler implements ICallbackHandler { * @param payload The payload object from gstaudit-server with callback arguments * @returns The result of the callback execution */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any async executeCallback(callbackId: string, payload: Record): Promise { const registration = this.callbacks.get(callbackId); diff --git a/gstaudit/lib/server/callback-manager.ts b/gstaudit/lib/server/callback-manager.ts index c9acd1f..02e6659 100644 --- a/gstaudit/lib/server/callback-manager.ts +++ b/gstaudit/lib/server/callback-manager.ts @@ -9,6 +9,7 @@ */ interface PendingCallback { + // eslint-disable-next-line @typescript-eslint/no-explicit-any resolve: (result: any) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; @@ -23,13 +24,14 @@ class CallbackManager { static getInstance(): CallbackManager { // Use global to ensure singleton across module contexts in Next.js - const globalAny = global as any; - if (!globalAny.__callbackManager) { - globalAny.__callbackManager = new CallbackManager(); + const globalWithManager = global as typeof global & { __callbackManager?: CallbackManager }; + if (!globalWithManager.__callbackManager) { + globalWithManager.__callbackManager = new CallbackManager(); } - return globalAny.__callbackManager; + return globalWithManager.__callbackManager; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any handleResponse(invocationId: string, result: any) { console.log(`[CallbackManager] Received callback response for ${invocationId}:`, result); console.log(`[CallbackManager] Pending callbacks:`, Array.from(this.pendingCallbacks.keys())); @@ -46,6 +48,7 @@ class CallbackManager { registerCallback( invocationId: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any resolve: (result: any) => void, reject: (error: Error) => void, timeoutMs: number = 30000 @@ -59,7 +62,9 @@ class CallbackManager { console.log(`[CallbackManager] Registered pending callback ${invocationId} (total pending: ${this.pendingCallbacks.size})`); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any createPromise(invocationId: string, timeoutMs: number = 30000): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any return new Promise((resolve, reject) => { this.registerCallback(invocationId, resolve, reject, timeoutMs); }); @@ -67,10 +72,12 @@ class CallbackManager { } // Export functions that use the singleton +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function handleCallbackResponse(invocationId: string, result: any) { CallbackManager.getInstance().handleResponse(invocationId, result); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function createCallbackPromise(invocationId: string, timeoutMs: number = 30000): Promise { return CallbackManager.getInstance().createPromise(invocationId, timeoutMs); } diff --git a/gstaudit/lib/server/connection-manager.ts b/gstaudit/lib/server/connection-manager.ts index 5428b82..97195fe 100644 --- a/gstaudit/lib/server/connection-manager.ts +++ b/gstaudit/lib/server/connection-manager.ts @@ -30,12 +30,12 @@ class ConnectionManager { static getInstance(): ConnectionManager { // Use global to ensure singleton across module contexts in Next.js - const globalAny = global as any; - if (!globalAny.__connectionManager) { + const globalWithManager = global as typeof global & { __connectionManager?: ConnectionManager }; + if (!globalWithManager.__connectionManager) { console.log('[ConnectionManager] Creating new singleton instance'); - globalAny.__connectionManager = new ConnectionManager(); + globalWithManager.__connectionManager = new ConnectionManager(); } - return globalAny.__connectionManager; + return globalWithManager.__connectionManager; } /** diff --git a/gstaudit/lib/server/websocket-handler.ts b/gstaudit/lib/server/websocket-handler.ts index 4a01dc1..9bef40b 100644 --- a/gstaudit/lib/server/websocket-handler.ts +++ b/gstaudit/lib/server/websocket-handler.ts @@ -26,6 +26,7 @@ export interface WebSocketConnection { ws: WebSocket; sessionId: string; connectionId: string; // The gstaudit-server this client is connected to + // eslint-disable-next-line @typescript-eslint/no-explicit-any sendMessage: (data: any) => void; } @@ -38,12 +39,12 @@ class WebSocketManager { } static getInstance(): WebSocketManager { - const globalAny = global as any; - if (!globalAny.__webSocketManager) { + const globalWithManager = global as typeof global & { __webSocketManager?: WebSocketManager }; + if (!globalWithManager.__webSocketManager) { console.log('[WebSocketManager] Creating new singleton instance'); - globalAny.__webSocketManager = new WebSocketManager(); + globalWithManager.__webSocketManager = new WebSocketManager(); } - return globalAny.__webSocketManager; + return globalWithManager.__webSocketManager; } /** @@ -54,6 +55,7 @@ class WebSocketManager { ws, sessionId, connectionId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any sendMessage: (data: any) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data));