gatewaysdk 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gatewaysdk/__init__.py +540 -0
- gatewaysdk/adapter/__init__.py +15 -0
- gatewaysdk/adapter/base.py +94 -0
- gatewaysdk/adapter/messages.py +270 -0
- gatewaysdk/adapter/triplet.py +1026 -0
- gatewaysdk/algorithm/__init__.py +39 -0
- gatewaysdk/algorithm/apo/__init__.py +5 -0
- gatewaysdk/algorithm/apo/apo.py +898 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant01.poml +22 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant02.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant01.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant02.poml +16 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant03.poml +107 -0
- gatewaysdk/algorithm/base.py +262 -0
- gatewaysdk/algorithm/decorator.py +264 -0
- gatewaysdk/algorithm/evals/__init__.py +7 -0
- gatewaysdk/algorithm/evals/evals.py +217 -0
- gatewaysdk/algorithm/fast.py +250 -0
- gatewaysdk/algorithm/gepa/__init__.py +61 -0
- gatewaysdk/algorithm/gepa/adapter.py +495 -0
- gatewaysdk/algorithm/gepa/gepa.py +570 -0
- gatewaysdk/algorithm/gepa/lib/__init__.py +18 -0
- gatewaysdk/algorithm/gepa/lib/adapters/README.md +12 -0
- gatewaysdk/algorithm/gepa/lib/adapters/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/README.md +341 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/__init__.py +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/anymaths_adapter.py +174 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/requirements.txt +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/README.md +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/default_adapter.py +209 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/README.md +7 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/dspy_adapter.py +307 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/README.md +99 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/dspy_program_proposal_signature.py +137 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/full_program_adapter.py +268 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/GEPA_RAG.md +621 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/__init__.py +56 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/evaluation_metrics.py +226 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/generic_rag_adapter.py +496 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/rag_pipeline.py +238 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_store_interface.py +212 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/__init__.py +2 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/chroma_store.py +196 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/lancedb_store.py +422 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/milvus_store.py +409 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/qdrant_store.py +368 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/weaviate_store.py +418 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/README.md +552 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/__init__.py +37 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_adapter.py +699 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_client.py +364 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/README.md +9 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/terminal_bench_adapter.py +217 -0
- gatewaysdk/algorithm/gepa/lib/api.py +382 -0
- gatewaysdk/algorithm/gepa/lib/core/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/core/adapter.py +180 -0
- gatewaysdk/algorithm/gepa/lib/core/data_loader.py +74 -0
- gatewaysdk/algorithm/gepa/lib/core/engine.py +379 -0
- gatewaysdk/algorithm/gepa/lib/core/result.py +233 -0
- gatewaysdk/algorithm/gepa/lib/core/state.py +636 -0
- gatewaysdk/algorithm/gepa/lib/examples/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/examples/aime.py +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/eval_default.py +111 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/instruction_prompt.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/optimal_prompt.txt +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/train_anymaths.py +177 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/arc_agi.ipynb +25705 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/example.ipynb +348 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/__init__.py +4 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/mcp_optimization_example.py +456 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/RAG_GUIDE.md +613 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/__init__.py +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/rag_optimization.py +820 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/requirements-rag.txt +29 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/instruction_prompt.txt +16 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/terminus.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/train_terminus.py +161 -0
- gatewaysdk/algorithm/gepa/lib/gepa_utils.py +117 -0
- gatewaysdk/algorithm/gepa/lib/logging/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/logging/experiment_tracker.py +187 -0
- gatewaysdk/algorithm/gepa/lib/logging/logger.py +75 -0
- gatewaysdk/algorithm/gepa/lib/logging/utils.py +103 -0
- gatewaysdk/algorithm/gepa/lib/proposer/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/base.py +31 -0
- gatewaysdk/algorithm/gepa/lib/proposer/merge.py +357 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/base.py +49 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/reflective_mutation.py +176 -0
- gatewaysdk/algorithm/gepa/lib/py.typed +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/batch_sampler.py +77 -0
- gatewaysdk/algorithm/gepa/lib/strategies/candidate_selector.py +50 -0
- gatewaysdk/algorithm/gepa/lib/strategies/component_selector.py +36 -0
- gatewaysdk/algorithm/gepa/lib/strategies/eval_policy.py +64 -0
- gatewaysdk/algorithm/gepa/lib/strategies/instruction_proposal.py +126 -0
- gatewaysdk/algorithm/gepa/lib/utils/__init__.py +10 -0
- gatewaysdk/algorithm/gepa/lib/utils/stop_condition.py +196 -0
- gatewaysdk/algorithm/gepa/tracing.py +105 -0
- gatewaysdk/algorithm/utils.py +177 -0
- gatewaysdk/algorithm/verl/__init__.py +5 -0
- gatewaysdk/algorithm/verl/interface.py +202 -0
- gatewaysdk/automations.py +111 -0
- gatewaysdk/benchmark_hub/README.md +88 -0
- gatewaysdk/benchmark_hub/__init__.py +119 -0
- gatewaysdk/benchmark_hub/_archive.py +189 -0
- gatewaysdk/benchmark_hub/_tar.py +6 -0
- gatewaysdk/benchmark_hub/client.py +992 -0
- gatewaysdk/benchmark_hub/dispatch_shard.py +34 -0
- gatewaysdk/benchmark_hub/eval_config.py +20 -0
- gatewaysdk/benchmark_hub/evals.py +351 -0
- gatewaysdk/benchmark_hub/harbor_adapter.py +673 -0
- gatewaysdk/benchmark_hub/path_utils.py +81 -0
- gatewaysdk/benchmark_hub/save_utils.py +101 -0
- gatewaysdk/benchmark_hub/verifiers_adapter.py +327 -0
- gatewaysdk/benchmark_hub/versioning.py +72 -0
- gatewaysdk/build.py +515 -0
- gatewaysdk/cli/__init__.py +58 -0
- gatewaysdk/cli/agent_runner.py +132 -0
- gatewaysdk/cli/http_client.py +115 -0
- gatewaysdk/cli/platform.py +4086 -0
- gatewaysdk/cli/prometheus.py +115 -0
- gatewaysdk/cli/release_gate.py +215 -0
- gatewaysdk/cli/store.py +131 -0
- gatewaysdk/cli/vllm.py +29 -0
- gatewaysdk/client.py +406 -0
- gatewaysdk/config.py +348 -0
- gatewaysdk/connectors/__init__.py +25 -0
- gatewaysdk/connectors/client.py +203 -0
- gatewaysdk/connectors/skill.py +25 -0
- gatewaysdk/connectors/template.py +106 -0
- gatewaysdk/context.py +606 -0
- gatewaysdk/emitter/__init__.py +43 -0
- gatewaysdk/emitter/annotation.py +370 -0
- gatewaysdk/emitter/exception.py +54 -0
- gatewaysdk/emitter/message.py +61 -0
- gatewaysdk/emitter/object.py +117 -0
- gatewaysdk/emitter/reward.py +320 -0
- gatewaysdk/env_var.py +156 -0
- gatewaysdk/environment/__init__.py +108 -0
- gatewaysdk/environment/_bundle.py +281 -0
- gatewaysdk/environment/_harbor.py +276 -0
- gatewaysdk/environment/_materialize.py +97 -0
- gatewaysdk/environment/_tar.py +33 -0
- gatewaysdk/environment/_world.py +346 -0
- gatewaysdk/environment/core.py +527 -0
- gatewaysdk/environment/errors.py +58 -0
- gatewaysdk/environment/runtime.py +150 -0
- gatewaysdk/environment/schema/__init__.py +35 -0
- gatewaysdk/environment/schema/__main__.py +225 -0
- gatewaysdk/environment/schema/_slack_fidelity.py +116 -0
- gatewaysdk/environment/schema/_slack_scenario.py +183 -0
- gatewaysdk/environment/schema/api.py +1628 -0
- gatewaysdk/environment/schema/batch.py +755 -0
- gatewaysdk/environment/schema/compiler.py +615 -0
- gatewaysdk/environment/schema/conform.py +428 -0
- gatewaysdk/environment/schema/connector.py +777 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/handlers.py +17 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/parity.json +28 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/provenance.json +233 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/scope.toml +112 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/world.json +1380 -0
- gatewaysdk/environment/schema/connectors/base.json +28 -0
- gatewaysdk/environment/schema/connectors/custom/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/custom/world.json +123 -0
- gatewaysdk/environment/schema/connectors/github/handlers.py +81 -0
- gatewaysdk/environment/schema/connectors/github/parity.json +37 -0
- gatewaysdk/environment/schema/connectors/github/provenance.json +134 -0
- gatewaysdk/environment/schema/connectors/github/scope.toml +158 -0
- gatewaysdk/environment/schema/connectors/github/world.json +5869 -0
- gatewaysdk/environment/schema/connectors/google-calendar/handlers.py +23 -0
- gatewaysdk/environment/schema/connectors/google-calendar/world.json +209 -0
- gatewaysdk/environment/schema/connectors/google-drive/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/google-drive/world.json +286 -0
- gatewaysdk/environment/schema/connectors/jamf/handlers.py +34 -0
- gatewaysdk/environment/schema/connectors/jamf/parity.json +49 -0
- gatewaysdk/environment/schema/connectors/jamf/provenance.json +181 -0
- gatewaysdk/environment/schema/connectors/jamf/scope.toml +128 -0
- gatewaysdk/environment/schema/connectors/jamf/world.json +2218 -0
- gatewaysdk/environment/schema/connectors/jira/handlers.py +28 -0
- gatewaysdk/environment/schema/connectors/jira/world.json +389 -0
- gatewaysdk/environment/schema/connectors/kandji/handlers.py +38 -0
- gatewaysdk/environment/schema/connectors/kandji/parity.json +29 -0
- gatewaysdk/environment/schema/connectors/kandji/provenance.json +152 -0
- gatewaysdk/environment/schema/connectors/kandji/scope.toml +120 -0
- gatewaysdk/environment/schema/connectors/kandji/world.json +765 -0
- gatewaysdk/environment/schema/connectors/linear/handlers.py +20 -0
- gatewaysdk/environment/schema/connectors/linear/world.json +363 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/fidelity.json +45 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/handlers.py +1 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/provenance.json +851 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/scope.md +231 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/teams-types.json +1710 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/world.json +858 -0
- gatewaysdk/environment/schema/connectors/netsuite/handlers.py +32 -0
- gatewaysdk/environment/schema/connectors/netsuite/parity.json +50 -0
- gatewaysdk/environment/schema/connectors/netsuite/provenance.json +137 -0
- gatewaysdk/environment/schema/connectors/netsuite/scope.toml +78 -0
- gatewaysdk/environment/schema/connectors/netsuite/world.json +16243 -0
- gatewaysdk/environment/schema/connectors/salesforce/handlers.py +806 -0
- gatewaysdk/environment/schema/connectors/salesforce/parity.json +46 -0
- gatewaysdk/environment/schema/connectors/salesforce/provenance.json +143 -0
- gatewaysdk/environment/schema/connectors/salesforce/scope.toml +174 -0
- gatewaysdk/environment/schema/connectors/salesforce/world.json +4695 -0
- gatewaysdk/environment/schema/connectors/slack/MODEL.md +236 -0
- gatewaysdk/environment/schema/connectors/slack/capabilities.json +1039 -0
- gatewaysdk/environment/schema/connectors/slack/handlers.py +762 -0
- gatewaysdk/environment/schema/connectors/slack/parity.json +65 -0
- gatewaysdk/environment/schema/connectors/slack/provenance.json +294 -0
- gatewaysdk/environment/schema/connectors/slack/scope.toml +44 -0
- gatewaysdk/environment/schema/connectors/slack/world.json +4993 -0
- gatewaysdk/environment/schema/connectors/workday/handlers.py +75 -0
- gatewaysdk/environment/schema/connectors/workday/parity.json +36 -0
- gatewaysdk/environment/schema/connectors/workday/provenance.json +179 -0
- gatewaysdk/environment/schema/connectors/workday/scope.toml +180 -0
- gatewaysdk/environment/schema/connectors/workday/world.json +806 -0
- gatewaysdk/environment/schema/data.py +593 -0
- gatewaysdk/environment/schema/extract.py +263 -0
- gatewaysdk/environment/schema/host.py +2437 -0
- gatewaysdk/environment/schema/host_surfaces.py +275 -0
- gatewaysdk/environment/schema/platform.py +1272 -0
- gatewaysdk/environment/schema/scaffold.py +250 -0
- gatewaysdk/environment/schema/skill.py +118 -0
- gatewaysdk/environment/schema/skills/agents/connector-template-author.md +202 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONNECTORS.md +331 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONVENTIONS.md +100 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/SKILL.md +313 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/ROWS.md +223 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/SKILL.md +293 -0
- gatewaysdk/environment/schema/skills/worlds-getting-started/SKILL.md +390 -0
- gatewaysdk/environment/schema/slack.py +757 -0
- gatewaysdk/environment/schema/snapshot.py +479 -0
- gatewaysdk/environment/schema/store.py +904 -0
- gatewaysdk/environment/schema/tasks.py +525 -0
- gatewaysdk/environment/schema/tools_world.py +322 -0
- gatewaysdk/environment/schema/validation.py +577 -0
- gatewaysdk/environment/schema/worker.py +78 -0
- gatewaysdk/environment/schema/workers.py +500 -0
- gatewaysdk/environment/schema/world_tests.py +336 -0
- gatewaysdk/environments/__init__.py +24 -0
- gatewaysdk/environments/client.py +486 -0
- gatewaysdk/environments/types.py +318 -0
- gatewaysdk/execution/__init__.py +15 -0
- gatewaysdk/execution/base.py +64 -0
- gatewaysdk/execution/client_server.py +443 -0
- gatewaysdk/execution/events.py +69 -0
- gatewaysdk/execution/inter_process.py +16 -0
- gatewaysdk/execution/shared_memory.py +282 -0
- gatewaysdk/experiments/__init__.py +90 -0
- gatewaysdk/experiments/assignment.py +177 -0
- gatewaysdk/experiments/client.py +877 -0
- gatewaysdk/experiments/exposure.py +222 -0
- gatewaysdk/experiments/types.py +81 -0
- gatewaysdk/importers/__init__.py +49 -0
- gatewaysdk/importers/_normalize.py +91 -0
- gatewaysdk/importers/builder.py +173 -0
- gatewaysdk/importers/client.py +95 -0
- gatewaysdk/importers/langsmith.py +196 -0
- gatewaysdk/importers/recipes.py +249 -0
- gatewaysdk/instrumentation/__init__.py +300 -0
- gatewaysdk/instrumentation/agentops.py +314 -0
- gatewaysdk/instrumentation/agentops_langchain.py +45 -0
- gatewaysdk/instrumentation/base.py +119 -0
- gatewaysdk/instrumentation/litellm.py +83 -0
- gatewaysdk/instrumentation/registry.py +273 -0
- gatewaysdk/instrumentation/vllm.py +81 -0
- gatewaysdk/instrumentation/weave.py +500 -0
- gatewaysdk/integrations/__init__.py +15 -0
- gatewaysdk/integrations/gateway/__init__.py +11 -0
- gatewaysdk/integrations/gateway/client.py +171 -0
- gatewaysdk/integrations/tool_access.py +549 -0
- gatewaysdk/litagent/__init__.py +11 -0
- gatewaysdk/litagent/decorator.py +536 -0
- gatewaysdk/litagent/litagent.py +252 -0
- gatewaysdk/llm_proxy.py +1742 -0
- gatewaysdk/logging.py +370 -0
- gatewaysdk/memory.py +278 -0
- gatewaysdk/personas.py +186 -0
- gatewaysdk/platform/__init__.py +17 -0
- gatewaysdk/platform/builder.py +221 -0
- gatewaysdk/platform/compatibility.py +67 -0
- gatewaysdk/platform/manifest.py +185 -0
- gatewaysdk/platform/orchestrator.py +901 -0
- gatewaysdk/platform/registry.py +122 -0
- gatewaysdk/platform/worker.py +864 -0
- gatewaysdk/replay/__init__.py +1032 -0
- gatewaysdk/replay/pytest.py +56 -0
- gatewaysdk/reward.py +7 -0
- gatewaysdk/run.py +1781 -0
- gatewaysdk/runner/__init__.py +11 -0
- gatewaysdk/runner/agent.py +878 -0
- gatewaysdk/runner/base.py +182 -0
- gatewaysdk/runner/legacy.py +309 -0
- gatewaysdk/security.py +700 -0
- gatewaysdk/semconv.py +170 -0
- gatewaysdk/server.py +399 -0
- gatewaysdk/sessions.py +282 -0
- gatewaysdk/store/__init__.py +45 -0
- gatewaysdk/store/base.py +908 -0
- gatewaysdk/store/client_server.py +2093 -0
- gatewaysdk/store/collection/__init__.py +30 -0
- gatewaysdk/store/collection/base.py +587 -0
- gatewaysdk/store/collection/memory.py +970 -0
- gatewaysdk/store/collection/mongo.py +1412 -0
- gatewaysdk/store/collection_based.py +1823 -0
- gatewaysdk/store/gateway.py +983 -0
- gatewaysdk/store/gateway_listener.py +465 -0
- gatewaysdk/store/listener.py +58 -0
- gatewaysdk/store/memory.py +396 -0
- gatewaysdk/store/mongo.py +165 -0
- gatewaysdk/store/redis_stream.py +517 -0
- gatewaysdk/store/sqlite.py +3 -0
- gatewaysdk/store/threading.py +370 -0
- gatewaysdk/store/utils.py +142 -0
- gatewaysdk/tracer/__init__.py +14 -0
- gatewaysdk/tracer/base.py +286 -0
- gatewaysdk/tracer/dummy.py +106 -0
- gatewaysdk/tracer/otel.py +559 -0
- gatewaysdk/tracing/__init__.py +110 -0
- gatewaysdk/tracing/api.py +808 -0
- gatewaysdk/tracing/attributes.py +9 -0
- gatewaysdk/tracing/context.py +272 -0
- gatewaysdk/tracing/exporters/__init__.py +10 -0
- gatewaysdk/tracing/exporters/gateway.py +228 -0
- gatewaysdk/tracing/identity.py +288 -0
- gatewaysdk/tracing/init.py +620 -0
- gatewaysdk/tracing/instrumentors/__init__.py +15 -0
- gatewaysdk/tracing/instrumentors/claude_agent_sdk.py +766 -0
- gatewaysdk/tracing/instrumentors/instrumentation_principles.md +294 -0
- gatewaysdk/tracing/instrumentors/registry.py +352 -0
- gatewaysdk/tracing/mapping.py +729 -0
- gatewaysdk/tracing/processors.py +393 -0
- gatewaysdk/tracing/push.py +617 -0
- gatewaysdk/tracing/push_models.py +247 -0
- gatewaysdk/tracing/semconv.py +294 -0
- gatewaysdk/tracing/span_builder.py +356 -0
- gatewaysdk/trainer/__init__.py +6 -0
- gatewaysdk/trainer/init_utils.py +263 -0
- gatewaysdk/trainer/legacy.py +359 -0
- gatewaysdk/trainer/registry.py +12 -0
- gatewaysdk/trainer/trainer.py +638 -0
- gatewaysdk/types/__init__.py +63 -0
- gatewaysdk/types/core.py +556 -0
- gatewaysdk/types/resources.py +204 -0
- gatewaysdk/types/tracer.py +515 -0
- gatewaysdk/types/tracing.py +162 -0
- gatewaysdk/users.py +251 -0
- gatewaysdk/utils/__init__.py +1 -0
- gatewaysdk/utils/id.py +18 -0
- gatewaysdk/utils/metrics.py +1025 -0
- gatewaysdk/utils/otel.py +550 -0
- gatewaysdk/utils/otlp.py +556 -0
- gatewaysdk/utils/redact.py +22 -0
- gatewaysdk/utils/server_launcher.py +1045 -0
- gatewaysdk/utils/system_snapshot.py +90 -0
- gatewaysdk/verl/__init__.py +8 -0
- gatewaysdk/verl/__main__.py +6 -0
- gatewaysdk/verl/async_server.py +46 -0
- gatewaysdk/verl/config.yaml +27 -0
- gatewaysdk/verl/daemon.py +1154 -0
- gatewaysdk/verl/dataset.py +44 -0
- gatewaysdk/verl/entrypoint.py +248 -0
- gatewaysdk/verl/trainer.py +549 -0
- gatewaysdk/world_browser.py +748 -0
- gatewaysdk/world_data.py +453 -0
- gatewaysdk/world_sessions.py +978 -0
- gatewaysdk/world_tasks.py +285 -0
- gatewaysdk/world_tools.py +115 -0
- gatewaysdk/worlds.py +639 -0
- gatewaysdk-0.3.2.dist-info/METADATA +236 -0
- gatewaysdk-0.3.2.dist-info/RECORD +376 -0
- gatewaysdk-0.3.2.dist-info/WHEEL +4 -0
- gatewaysdk-0.3.2.dist-info/entry_points.txt +5 -0
- gatewaysdk-0.3.2.dist-info/licenses/LICENSE +19 -0
|
@@ -0,0 +1,1628 @@
|
|
|
1
|
+
"""The world's API: the vendor's HTTP surface, served from the world itself.
|
|
2
|
+
|
|
3
|
+
A provider's client speaks the vendor's REST paths, not tool calls. The world
|
|
4
|
+
answers those paths from its store, so the same ``connector.toml`` that
|
|
5
|
+
captured from the real service now describes how the world serves it —
|
|
6
|
+
reads, writes, and whatever logic sits between them:
|
|
7
|
+
|
|
8
|
+
[api]
|
|
9
|
+
envelope = "results" # rows land under this key with hits and cursor; "" = bare array
|
|
10
|
+
hits_key = "hits" # the envelope's count and next-page keys, named the vendor's way
|
|
11
|
+
cursor_key = "cursor"
|
|
12
|
+
page = "cursor" # none | offset | cursor: how list routes paginate
|
|
13
|
+
limit_param = "limit" # the query parameters the vendor's client sends for a page
|
|
14
|
+
offset_param = "offset"
|
|
15
|
+
cursor_param = "cursor"
|
|
16
|
+
page_size = 100
|
|
17
|
+
unauthorized = '{"detail": "Authentication credentials were not provided."}'
|
|
18
|
+
not_found = '{"detail": "Not found."}'
|
|
19
|
+
invalid = '{"detail": "Invalid request."}'
|
|
20
|
+
error_content_type = "application/problem+json" # what error bodies are labelled; the vendor's choice
|
|
21
|
+
unauthorized_status = 401 # some vendors answer 403 for a bad key; say what the vendor says
|
|
22
|
+
not_found_status = 404
|
|
23
|
+
invalid_status = 400
|
|
24
|
+
token_path = "/oauth/token" # oauth2 worlds mint bearer tokens here, the way the vendor does
|
|
25
|
+
handlers = "api/handlers.py" # the world's own logic (default when the file exists)
|
|
26
|
+
before = "gate" # optional: runs before every operation; returns None or an answer
|
|
27
|
+
|
|
28
|
+
[[operations]]
|
|
29
|
+
name = "get_records_by_email"
|
|
30
|
+
path = "/breach/data/emails/{email}"
|
|
31
|
+
entity = "spycloud_records"
|
|
32
|
+
[operations.api]
|
|
33
|
+
filter = { email = "attributes.email" } # path argument or query parameter -> row field (dotted),
|
|
34
|
+
# matched case-insensitively; an absent parameter does not filter
|
|
35
|
+
|
|
36
|
+
[[operations]]
|
|
37
|
+
name = "create_search"
|
|
38
|
+
method = "POST"
|
|
39
|
+
path = "/searches"
|
|
40
|
+
entity = "torchlight_searches"
|
|
41
|
+
[operations.api]
|
|
42
|
+
action = "create" # read | create | update | delete | handler
|
|
43
|
+
status = 201 # what the vendor answers with
|
|
44
|
+
single = true # the row itself, not a list envelope
|
|
45
|
+
body = { name = "name", description = "description" } # row field <- request body path
|
|
46
|
+
defaults = { status = "drafted" }
|
|
47
|
+
generate = { id = "uuid", createdAt = "now" } # uuid | int | now
|
|
48
|
+
|
|
49
|
+
[[operations]]
|
|
50
|
+
name = "execute_search"
|
|
51
|
+
method = "POST"
|
|
52
|
+
path = "/searches/{id}/execute"
|
|
53
|
+
entity = "torchlight_searches"
|
|
54
|
+
[operations.api]
|
|
55
|
+
action = "handler"
|
|
56
|
+
handler = "execute_search" # def execute_search(ctx, args, query, body) in api/handlers.py
|
|
57
|
+
|
|
58
|
+
An ``action = "handler"`` operation runs the world's own Python: ``ctx`` reads
|
|
59
|
+
and writes the store (``rows``, ``get``, ``insert``, ``update``, ``delete``),
|
|
60
|
+
tells the time (``now``, ``elapsed``, ``ladder`` for status machines that
|
|
61
|
+
advance on a clock), mints ids (``uuid``) and wraps answers (``envelope``,
|
|
62
|
+
``not_found``). That is where computed results and workflow transitions live,
|
|
63
|
+
in the customer's world, never in this package.
|
|
64
|
+
|
|
65
|
+
``serve`` runs the world's API locally with the standard library alone.
|
|
66
|
+
``build_api_bundle`` writes the api-kind bundle the platform hosts (``gateway-api-world.toml``,
|
|
67
|
+
``openapi.json``, ``seed/``, ``scenarios/``, ``server/``): the compiled world
|
|
68
|
+
and this package are copied into ``server/`` so the container needs nothing
|
|
69
|
+
installed, and the bundle root stays legacy-shaped because the api runner
|
|
70
|
+
refuses a schema bundle at its root. The runner's admin endpoints
|
|
71
|
+
(``/healthz``, ``/__world/seed``, ``/__world/reset``, ``/__world/state``) are
|
|
72
|
+
served beside the vendor routes.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
from __future__ import annotations
|
|
76
|
+
|
|
77
|
+
import base64
|
|
78
|
+
import importlib.util
|
|
79
|
+
import json
|
|
80
|
+
import os
|
|
81
|
+
import re
|
|
82
|
+
import secrets
|
|
83
|
+
import shutil
|
|
84
|
+
import sys
|
|
85
|
+
import tempfile
|
|
86
|
+
import threading
|
|
87
|
+
import time
|
|
88
|
+
import tomllib
|
|
89
|
+
import urllib.parse
|
|
90
|
+
import uuid
|
|
91
|
+
from dataclasses import dataclass, field
|
|
92
|
+
from datetime import datetime, timezone
|
|
93
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
94
|
+
from pathlib import Path
|
|
95
|
+
from typing import Any, Callable
|
|
96
|
+
|
|
97
|
+
from . import snapshot as snapshots
|
|
98
|
+
from .compiler import check_bundle, compile_bundle
|
|
99
|
+
from .connector import DEFAULT_TOKEN_PATH, SPEC, Operation, Spec, load_spec
|
|
100
|
+
from .store import SQLiteStore
|
|
101
|
+
from .validation import ContractError, fail, read_json
|
|
102
|
+
|
|
103
|
+
ADMIN = {"seed": "/__world/seed", "reset": "/__world/reset", "state": "/__world/state", "health": "/healthz"}
|
|
104
|
+
PORT = 8080
|
|
105
|
+
FILTER_KEY = "api"
|
|
106
|
+
HANDLERS = "api/handlers.py"
|
|
107
|
+
SCENARIOS = "scenarios"
|
|
108
|
+
BEFORE = "__before__"
|
|
109
|
+
ACTIONS = ("read", "create", "update", "delete", "handler")
|
|
110
|
+
MATCHES = ("equals", "contains", "prefix")
|
|
111
|
+
PAGING = ("none", "offset", "cursor")
|
|
112
|
+
GENERATORS = ("uuid", "int", "now")
|
|
113
|
+
#: Set to an epoch to freeze the world's clock (tests, reproducible workflows).
|
|
114
|
+
CLOCK_ENV = "GATEWAY_WORLD_CLOCK"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------- settings
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class Paging:
|
|
122
|
+
style: str = "none"
|
|
123
|
+
limit_param: str = "limit"
|
|
124
|
+
offset_param: str = "offset"
|
|
125
|
+
cursor_param: str = "cursor"
|
|
126
|
+
page_size: int = 100
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class OperationSettings:
|
|
131
|
+
filter: dict[str, str] = field(default_factory=dict) # arg -> row field path
|
|
132
|
+
match: dict[str, str] = field(default_factory=dict) # arg -> equals | contains | prefix
|
|
133
|
+
action: str = "read"
|
|
134
|
+
status: int = 200
|
|
135
|
+
envelope: str | None = None # None inherits [api].envelope
|
|
136
|
+
single: bool = False
|
|
137
|
+
body: dict[str, str] = field(default_factory=dict) # row field <- body path; empty = the body as-is
|
|
138
|
+
defaults: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
generate: dict[str, str] = field(default_factory=dict)
|
|
140
|
+
handler: str | None = None
|
|
141
|
+
paging: str | None = None # None inherits [api].page
|
|
142
|
+
sample: dict[str, str] = field(default_factory=dict) # arg -> a value the route accepts, for conformance runs
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class UiSettings:
|
|
147
|
+
"""``[ui]`` in connector.toml: a prebuilt static single-page app shipped
|
|
148
|
+
with the world and served by the World Host on the session's ui origin."""
|
|
149
|
+
|
|
150
|
+
#: Directory in the world holding ``index.html`` and its assets.
|
|
151
|
+
static: str
|
|
152
|
+
#: The page an agent lands on first.
|
|
153
|
+
entry: str = "/"
|
|
154
|
+
#: Same-origin prefix the SPA calls the api at; the host rewrites it.
|
|
155
|
+
api_path: str = "/__world-api"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class ApiSettings:
|
|
160
|
+
envelope: str
|
|
161
|
+
unauthorized: Any
|
|
162
|
+
not_found: Any
|
|
163
|
+
filters: dict[str, dict[str, str]] # operation -> {arg: row field path}; kept for callers that read it
|
|
164
|
+
unauthorized_status: int = 401
|
|
165
|
+
not_found_status: int = 404
|
|
166
|
+
invalid: Any = None
|
|
167
|
+
invalid_status: int = 400
|
|
168
|
+
error_content_type: str = "application/json"
|
|
169
|
+
hits_key: str = "hits"
|
|
170
|
+
cursor_key: str = "cursor"
|
|
171
|
+
previous_key: str | None = None # emit the previous page's cursor under this key (DRF's "previous")
|
|
172
|
+
page_headers: dict[str, str] = field(default_factory=dict) # cursor | total | previous -> response header name
|
|
173
|
+
page_links: bool = False # next/previous as absolute URLs of the served host, the way DRF answers
|
|
174
|
+
paging: Paging = field(default_factory=Paging)
|
|
175
|
+
token_path: str | None = None
|
|
176
|
+
handlers: Path | None = None
|
|
177
|
+
before: str | None = None # a function in the handlers module run before every operation
|
|
178
|
+
operations: dict[str, OperationSettings] = field(default_factory=dict)
|
|
179
|
+
ui: UiSettings | None = None
|
|
180
|
+
|
|
181
|
+
def for_operation(self, name: str) -> OperationSettings:
|
|
182
|
+
return self.operations.get(name, OperationSettings())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _json_body(table: dict, key: str, default: Any, location: str) -> Any:
|
|
186
|
+
text = table.get(key)
|
|
187
|
+
if text is None:
|
|
188
|
+
return default
|
|
189
|
+
if not isinstance(text, str):
|
|
190
|
+
fail("spec", f"{key} must be a JSON string", f"{location}/{key}")
|
|
191
|
+
try:
|
|
192
|
+
return json.loads(text)
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
fail("spec", f"{key} is not valid JSON", f"{location}/{key}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _status(table: dict, key: str, default: int, location: str, *, low: int, high: int) -> int:
|
|
198
|
+
value = table.get(key, default)
|
|
199
|
+
if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
|
|
200
|
+
fail("spec", f"{key} must be an HTTP status between {low} and {high}", f"{location}/{key}")
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _page_headers(api: dict) -> dict[str, str]:
|
|
205
|
+
"""``page_headers = { cursor = "Cursor", total = "Total-Count" }``: vendors that page by
|
|
206
|
+
response header (Torchlight) get the same values there as in the envelope."""
|
|
207
|
+
mapping = api.get("page_headers", {})
|
|
208
|
+
if not isinstance(mapping, dict) or not all(
|
|
209
|
+
k in ("cursor", "total", "previous") and isinstance(v, str) and v.strip() for k, v in mapping.items()
|
|
210
|
+
):
|
|
211
|
+
fail("spec", "api.page_headers maps cursor, total and previous to response header names", "/api/page_headers")
|
|
212
|
+
return dict(mapping)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _flag(table: dict, key: str, location: str) -> bool:
|
|
216
|
+
value = table.get(key, False)
|
|
217
|
+
if not isinstance(value, bool):
|
|
218
|
+
fail("spec", f"{key} must be true or false", f"{location}/{key}")
|
|
219
|
+
return value
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _name(table: dict, key: str, default: str, location: str) -> str:
|
|
223
|
+
value = table.get(key, default)
|
|
224
|
+
if not isinstance(value, str) or not value.strip():
|
|
225
|
+
fail("spec", f"{key} must be a non-empty string", f"{location}/{key}")
|
|
226
|
+
return value
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _str_map(table: dict, key: str, location: str) -> dict[str, str]:
|
|
230
|
+
mapping = table.get(key, {})
|
|
231
|
+
if not isinstance(mapping, dict) or not all(
|
|
232
|
+
isinstance(k, str) and isinstance(v, str) and k for k, v in mapping.items()
|
|
233
|
+
):
|
|
234
|
+
fail("spec", f"{key} maps names to strings", f"{location}/{key}")
|
|
235
|
+
return dict(mapping)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _operation_settings(entry: dict, operation: Operation, location: str) -> OperationSettings:
|
|
239
|
+
table = entry.get(FILTER_KEY, {})
|
|
240
|
+
if not isinstance(table, dict):
|
|
241
|
+
fail("spec", "[operations.api] must be a table", location)
|
|
242
|
+
mapping = _str_map(table, "filter", location)
|
|
243
|
+
for arg in mapping:
|
|
244
|
+
if arg not in operation.placeholders and arg not in operation.params:
|
|
245
|
+
fail(
|
|
246
|
+
"spec",
|
|
247
|
+
f"api.filter names {arg}, which is neither a path argument nor a parameter",
|
|
248
|
+
f"{location}/filter",
|
|
249
|
+
)
|
|
250
|
+
match = _str_map(table, "match", location)
|
|
251
|
+
for arg, mode in match.items():
|
|
252
|
+
if arg not in mapping:
|
|
253
|
+
fail("spec", f"api.match names {arg}, which api.filter does not declare", f"{location}/match")
|
|
254
|
+
if mode not in MATCHES:
|
|
255
|
+
fail("spec", f"api.match.{arg} must be one of {', '.join(MATCHES)}", f"{location}/match")
|
|
256
|
+
action = _name(table, "action", "read", location)
|
|
257
|
+
if action not in ACTIONS:
|
|
258
|
+
fail("spec", f"api.action must be one of {', '.join(ACTIONS)}", f"{location}/action")
|
|
259
|
+
if action in ("create", "update", "delete") and not operation.entity:
|
|
260
|
+
fail("spec", f"api.action {action} needs an entity to write", f"{location}/action")
|
|
261
|
+
if action in ("update", "delete") and not mapping:
|
|
262
|
+
fail("spec", f"api.action {action} needs a filter to find the row", f"{location}/filter")
|
|
263
|
+
handler = table.get("handler")
|
|
264
|
+
if action == "handler":
|
|
265
|
+
if not isinstance(handler, str) or not re.match(r"^[a-z_][a-z0-9_]*$", handler):
|
|
266
|
+
fail("spec", "api.handler names a function in the world's handlers module", f"{location}/handler")
|
|
267
|
+
elif handler is not None:
|
|
268
|
+
fail("spec", 'api.handler belongs to action = "handler"', f"{location}/handler")
|
|
269
|
+
default_status = {"create": 201, "delete": 204}.get(action, 200)
|
|
270
|
+
status = _status(table, "status", default_status, location, low=200, high=299)
|
|
271
|
+
envelope = table.get("envelope")
|
|
272
|
+
if envelope is not None and not isinstance(envelope, str):
|
|
273
|
+
fail("spec", "api.envelope must be a string", f"{location}/envelope")
|
|
274
|
+
single = table.get("single", action in ("create", "update"))
|
|
275
|
+
if not isinstance(single, bool):
|
|
276
|
+
fail("spec", "api.single must be true or false", f"{location}/single")
|
|
277
|
+
body = _str_map(table, "body", location)
|
|
278
|
+
defaults = table.get("defaults", {})
|
|
279
|
+
if not isinstance(defaults, dict):
|
|
280
|
+
fail("spec", "api.defaults is a table of constant fields", f"{location}/defaults")
|
|
281
|
+
generate = _str_map(table, "generate", location)
|
|
282
|
+
for field_name, kind in generate.items():
|
|
283
|
+
if kind not in GENERATORS:
|
|
284
|
+
fail("spec", f"api.generate.{field_name} must be one of {', '.join(GENERATORS)}", f"{location}/generate")
|
|
285
|
+
if (body or defaults or generate) and action not in ("create", "update"):
|
|
286
|
+
fail("spec", "api.body, defaults and generate belong to create and update actions", location)
|
|
287
|
+
sample = table.get("sample", {})
|
|
288
|
+
if not isinstance(sample, dict) or not all(
|
|
289
|
+
isinstance(k, str) and isinstance(v, (str, int, float)) for k, v in sample.items()
|
|
290
|
+
):
|
|
291
|
+
fail("spec", "api.sample maps path arguments or parameters to a value the route accepts", f"{location}/sample")
|
|
292
|
+
for arg in sample:
|
|
293
|
+
if arg not in operation.placeholders and arg not in operation.params:
|
|
294
|
+
fail(
|
|
295
|
+
"spec",
|
|
296
|
+
f"api.sample names {arg}, which is neither a path argument nor a parameter",
|
|
297
|
+
f"{location}/sample",
|
|
298
|
+
)
|
|
299
|
+
paging = table.get("page")
|
|
300
|
+
if paging is not None and paging not in PAGING:
|
|
301
|
+
fail("spec", f"api.page must be one of {', '.join(PAGING)}", f"{location}/page")
|
|
302
|
+
return OperationSettings(
|
|
303
|
+
filter=mapping,
|
|
304
|
+
match=match,
|
|
305
|
+
action=action,
|
|
306
|
+
status=status,
|
|
307
|
+
envelope=envelope,
|
|
308
|
+
single=single,
|
|
309
|
+
body=body,
|
|
310
|
+
defaults=dict(defaults),
|
|
311
|
+
generate=generate,
|
|
312
|
+
handler=handler if action == "handler" else None,
|
|
313
|
+
paging=paging,
|
|
314
|
+
sample={k: str(v) for k, v in sample.items()},
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Directory names every pusher (``bench push`` in Python and TypeScript, the
|
|
319
|
+
# web upload) drops from a bundle at any depth, as build artifacts. A built ui
|
|
320
|
+
# under one of them never reaches the hub, so the manifest refuses it here
|
|
321
|
+
# rather than the host refusing every open of the version.
|
|
322
|
+
ARCHIVE_DROPPED_DIRS = frozenset({"dist", "build", "outputs", "__pycache__"})
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _ui_settings(raw: dict, world: Path) -> UiSettings | None:
|
|
326
|
+
ui = raw.get("ui")
|
|
327
|
+
if ui is None:
|
|
328
|
+
return None
|
|
329
|
+
if not isinstance(ui, dict):
|
|
330
|
+
fail("spec", "[ui] must be a table", "/ui")
|
|
331
|
+
static = ui.get("static")
|
|
332
|
+
if not isinstance(static, str) or not static or static.startswith("/") or ".." in static.split("/"):
|
|
333
|
+
fail("spec", "ui.static is a directory inside the world holding the built ui", "/ui/static")
|
|
334
|
+
dropped = [part for part in static.split("/") if part in ARCHIVE_DROPPED_DIRS or part.startswith(".")]
|
|
335
|
+
if dropped:
|
|
336
|
+
fail(
|
|
337
|
+
"spec",
|
|
338
|
+
f"ui.static = {static!r} is under {dropped[0]!r}, which bench push drops from every bundle "
|
|
339
|
+
"as a build artifact; build the ui into a directory such as ui/static",
|
|
340
|
+
"/ui/static",
|
|
341
|
+
)
|
|
342
|
+
if not (world / static / "index.html").is_file():
|
|
343
|
+
fail("spec", f"ui.static = {static!r} has no index.html; build the ui first", "/ui/static")
|
|
344
|
+
entry = ui.get("entry", "/")
|
|
345
|
+
if not isinstance(entry, str) or not entry.startswith("/"):
|
|
346
|
+
fail("spec", "ui.entry is the path the agent lands on, starting with /", "/ui/entry")
|
|
347
|
+
api_path = ui.get("api_path", "/__world-api")
|
|
348
|
+
if not isinstance(api_path, str) or not api_path.startswith("/") or api_path == "/":
|
|
349
|
+
fail("spec", "ui.api_path is the same-origin prefix the ui calls the api at", "/ui/api_path")
|
|
350
|
+
return UiSettings(static=static.rstrip("/"), entry=entry, api_path=api_path.rstrip("/"))
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def load_api_settings(world: Path, spec: Spec) -> ApiSettings:
|
|
354
|
+
raw = tomllib.loads((world / SPEC).read_text(encoding="utf-8"))
|
|
355
|
+
api = raw.get(FILTER_KEY, {})
|
|
356
|
+
if not isinstance(api, dict):
|
|
357
|
+
fail("spec", "[api] must be a table", "/api")
|
|
358
|
+
envelope = api.get("envelope", "")
|
|
359
|
+
if not isinstance(envelope, str):
|
|
360
|
+
fail("spec", "api.envelope must be a string", "/api/envelope")
|
|
361
|
+
style = _name(api, "page", "none", "/api")
|
|
362
|
+
if style not in PAGING:
|
|
363
|
+
fail("spec", f"api.page must be one of {', '.join(PAGING)}", "/api/page")
|
|
364
|
+
page_size = api.get("page_size", 100)
|
|
365
|
+
if isinstance(page_size, bool) or not isinstance(page_size, int) or page_size < 1:
|
|
366
|
+
fail("spec", "api.page_size must be a positive integer", "/api/page_size")
|
|
367
|
+
paging = Paging(
|
|
368
|
+
style=style,
|
|
369
|
+
limit_param=_name(api, "limit_param", "limit", "/api"),
|
|
370
|
+
offset_param=_name(api, "offset_param", "offset", "/api"),
|
|
371
|
+
cursor_param=_name(api, "cursor_param", "cursor", "/api"),
|
|
372
|
+
page_size=page_size,
|
|
373
|
+
)
|
|
374
|
+
token_path = api.get("token_path")
|
|
375
|
+
if token_path is not None and (not isinstance(token_path, str) or not token_path.startswith("/")):
|
|
376
|
+
fail("spec", "api.token_path is the path the world mints tokens on", "/api/token_path")
|
|
377
|
+
if token_path is not None and spec.scheme != "oauth2":
|
|
378
|
+
fail("spec", "api.token_path belongs to auth.scheme oauth2", "/api/token_path")
|
|
379
|
+
if spec.scheme == "oauth2" and token_path is None:
|
|
380
|
+
token_path = spec.token_url if (spec.token_url or "").startswith("/") else DEFAULT_TOKEN_PATH
|
|
381
|
+
handlers_name = api.get("handlers")
|
|
382
|
+
if handlers_name is not None and not isinstance(handlers_name, str):
|
|
383
|
+
fail("spec", "api.handlers is the path of the world's handlers module", "/api/handlers")
|
|
384
|
+
handlers = world / (handlers_name or HANDLERS)
|
|
385
|
+
if handlers_name is not None and not handlers.is_file():
|
|
386
|
+
fail("spec", f"api.handlers names {handlers_name}, which is not a file in the world", "/api/handlers")
|
|
387
|
+
operations: dict[str, OperationSettings] = {}
|
|
388
|
+
for index, entry in enumerate(raw.get("operations", [])):
|
|
389
|
+
location = f"/operations/{index}/api"
|
|
390
|
+
operation = spec.operations[entry["name"]]
|
|
391
|
+
operations[operation.name] = _operation_settings(entry, operation, location)
|
|
392
|
+
before = api.get("before")
|
|
393
|
+
if before is not None and (not isinstance(before, str) or not re.match(r"^[a-z_][a-z0-9_]*$", before)):
|
|
394
|
+
fail("spec", "api.before names a function in the world's handlers module", "/api/before")
|
|
395
|
+
needs_handlers = [name for name, op in operations.items() if op.handler] + (
|
|
396
|
+
[f"[api].before={before}"] if before else []
|
|
397
|
+
)
|
|
398
|
+
if needs_handlers and not handlers.is_file():
|
|
399
|
+
fail(
|
|
400
|
+
"spec",
|
|
401
|
+
f"Operations {', '.join(needs_handlers)} name handlers but the world has no {handlers.name}",
|
|
402
|
+
"/api/handlers",
|
|
403
|
+
)
|
|
404
|
+
return ApiSettings(
|
|
405
|
+
envelope=envelope,
|
|
406
|
+
unauthorized=_json_body(api, "unauthorized", {"error": "unauthorized"}, "/api"),
|
|
407
|
+
not_found=_json_body(api, "not_found", {"error": "not_found"}, "/api"),
|
|
408
|
+
filters={name: dict(op.filter) for name, op in operations.items()},
|
|
409
|
+
unauthorized_status=_status(api, "unauthorized_status", 401, "/api", low=400, high=599),
|
|
410
|
+
not_found_status=_status(api, "not_found_status", 404, "/api", low=400, high=599),
|
|
411
|
+
invalid=_json_body(api, "invalid", {"error": "invalid"}, "/api"),
|
|
412
|
+
invalid_status=_status(api, "invalid_status", 400, "/api", low=400, high=599),
|
|
413
|
+
error_content_type=_name(api, "error_content_type", "application/json", "/api"),
|
|
414
|
+
hits_key=_name(api, "hits_key", "hits", "/api"),
|
|
415
|
+
cursor_key=_name(api, "cursor_key", "cursor", "/api"),
|
|
416
|
+
previous_key=_name(api, "previous_key", "", "/api") if api.get("previous_key") is not None else None,
|
|
417
|
+
page_headers=_page_headers(api),
|
|
418
|
+
page_links=_flag(api, "page_links", "/api"),
|
|
419
|
+
paging=paging,
|
|
420
|
+
token_path=token_path,
|
|
421
|
+
handlers=handlers if handlers.is_file() else None,
|
|
422
|
+
before=before,
|
|
423
|
+
operations=operations,
|
|
424
|
+
ui=_ui_settings(raw, world),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------- state
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class WorldState:
|
|
432
|
+
"""The store behind the served API.
|
|
433
|
+
|
|
434
|
+
With a compiled snapshot in the tree (``db/state.sqlite`` whose sidecar names
|
|
435
|
+
this contract) a reset is a file copy and an open; without one the world's
|
|
436
|
+
initial batch is replayed, as it always was. ``snapshot`` is the sidecar of the
|
|
437
|
+
snapshot in use, or None on the legacy path."""
|
|
438
|
+
|
|
439
|
+
def __init__(self, world: Path):
|
|
440
|
+
self.world = world
|
|
441
|
+
self.contract = check_bundle(world)
|
|
442
|
+
self._initial: dict | None = None
|
|
443
|
+
self._snapshot_path = snapshots.default_snapshot(world)
|
|
444
|
+
# Only a snapshot that IS the tree's data through this contract (or the
|
|
445
|
+
# platform's pin) is opened; one left behind by an earlier compile while the
|
|
446
|
+
# journals grew is stale, and the journals are replayed instead.
|
|
447
|
+
self.snapshot: dict | None = snapshots.current_default(world, self.contract)
|
|
448
|
+
self._dir = tempfile.TemporaryDirectory(prefix="gateway-world-api-")
|
|
449
|
+
self._lock = threading.Lock()
|
|
450
|
+
self.store: SQLiteStore | None = None
|
|
451
|
+
self.reset()
|
|
452
|
+
|
|
453
|
+
@property
|
|
454
|
+
def digest(self) -> str | None:
|
|
455
|
+
"""The batch digest of the snapshot the world is on; None on the legacy path."""
|
|
456
|
+
return self.snapshot["batchDigest"] if self.snapshot else None
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def initial(self) -> dict:
|
|
460
|
+
"""The world's initial batch, read when the legacy path needs it: a snapshot
|
|
461
|
+
start never parses the JSON at all."""
|
|
462
|
+
if self._initial is None:
|
|
463
|
+
self._initial = read_json(self.world / "data/initial.json")
|
|
464
|
+
return self._initial
|
|
465
|
+
|
|
466
|
+
def reset(self, *, initial: bool = True) -> None:
|
|
467
|
+
"""A fresh store: the snapshot copied (or the world's initial batch applied),
|
|
468
|
+
or empty when a legacy seed is about to supply the whole state (the runner's
|
|
469
|
+
base + overlay seed carries the initial rows itself; applying both would
|
|
470
|
+
collide on every key)."""
|
|
471
|
+
with self._lock:
|
|
472
|
+
self._open(self._snapshot_path if initial and self.snapshot else None, initial=initial)
|
|
473
|
+
|
|
474
|
+
def _open(self, snapshot: Path | None, *, initial: bool = True) -> None:
|
|
475
|
+
if self.store is not None:
|
|
476
|
+
self.store.close()
|
|
477
|
+
database = Path(self._dir.name) / f"{uuid.uuid4().hex}.db"
|
|
478
|
+
if snapshot is not None:
|
|
479
|
+
self.store = snapshots.open_copy(snapshot, self.contract, database)
|
|
480
|
+
return
|
|
481
|
+
self.store = SQLiteStore.create(database, self.contract)
|
|
482
|
+
if initial:
|
|
483
|
+
self.store.apply_batch(self.initial)
|
|
484
|
+
|
|
485
|
+
def seed(self, body: Any) -> dict:
|
|
486
|
+
"""The runner's seed. Two bodies: the legacy whole state ``{entity: [rows]}``
|
|
487
|
+
(inserted on top of an empty world) and the overlay ``{"base": digest, "rows":
|
|
488
|
+
{entity: [rows]}, "mode": append|replace}`` (the snapshot reset, then the rows
|
|
489
|
+
applied on top). An overlay whose base is not the snapshot this world is on is
|
|
490
|
+
refused with ``base_mismatch`` naming the expected digest: a version mismatch
|
|
491
|
+
is an error, never a silent full reseed."""
|
|
492
|
+
if not isinstance(body, dict):
|
|
493
|
+
fail("seed", "Seed must be an object of entity -> rows", "/seed")
|
|
494
|
+
if isinstance(body.get("base"), str) and isinstance(body.get("rows"), dict):
|
|
495
|
+
return self._seed_overlay(body)
|
|
496
|
+
mutations = []
|
|
497
|
+
for entity, rows in body.items():
|
|
498
|
+
if entity not in self.contract.entities:
|
|
499
|
+
fail("seed", f"Unknown entity {entity}", f"/seed/{entity}")
|
|
500
|
+
if not isinstance(rows, list):
|
|
501
|
+
fail("seed", "Rows must be an array", f"/seed/{entity}")
|
|
502
|
+
for row in rows:
|
|
503
|
+
mutations.append(
|
|
504
|
+
{"op": "insert", "entity": entity, "value": row, "source": {"record": f"api-seed:{entity}"}}
|
|
505
|
+
)
|
|
506
|
+
self.reset(initial=False)
|
|
507
|
+
if not mutations:
|
|
508
|
+
return {"applied": 0}
|
|
509
|
+
with self._lock:
|
|
510
|
+
assert self.store is not None
|
|
511
|
+
return self.store.apply_batch(
|
|
512
|
+
{
|
|
513
|
+
"id": f"api-seed-{uuid.uuid4().hex}",
|
|
514
|
+
"target": self.contract.target,
|
|
515
|
+
"mapping": {"id": "api-seed", "version": "1"},
|
|
516
|
+
"coverage": "partial",
|
|
517
|
+
"mutations": mutations,
|
|
518
|
+
}
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
def _seed_overlay(self, body: dict) -> dict:
|
|
522
|
+
from .data import MODES
|
|
523
|
+
|
|
524
|
+
mode = body.get("mode", "append")
|
|
525
|
+
if mode not in MODES:
|
|
526
|
+
fail("seed", f"mode must be one of {', '.join(MODES)}", "/seed/mode")
|
|
527
|
+
if body["base"] != self.digest:
|
|
528
|
+
fail("base_mismatch", "The overlay's base is not the snapshot this world is on", expected=self.digest)
|
|
529
|
+
self.reset()
|
|
530
|
+
return self.seed_live(body["rows"], mode)
|
|
531
|
+
|
|
532
|
+
def seed_live(self, rows: dict, mode: str = "append") -> dict:
|
|
533
|
+
"""Rows onto the world as it stands, no reset: ``append`` upserts by primary
|
|
534
|
+
key, ``replace`` swaps the named entities. The session's seed, so tool calls
|
|
535
|
+
made so far and the scenario's own rows stay."""
|
|
536
|
+
from .data import MODES, seed_batch
|
|
537
|
+
|
|
538
|
+
if mode not in MODES:
|
|
539
|
+
fail("seed", f"mode must be one of {', '.join(MODES)}", "/seed/mode")
|
|
540
|
+
with self._lock:
|
|
541
|
+
assert self.store is not None
|
|
542
|
+
batch = seed_batch(self.store, self.contract, rows, mode, batch_id=f"api-seed-{uuid.uuid4().hex}")
|
|
543
|
+
return self.store.apply_batch(batch)
|
|
544
|
+
|
|
545
|
+
def scenarios(self) -> dict[str, Path]:
|
|
546
|
+
"""The world's own ``scenarios/<name>/scenario.toml`` dirs: a starting state each."""
|
|
547
|
+
return _scenario_dirs(self.world)
|
|
548
|
+
|
|
549
|
+
def apply_scenario(self, name: str) -> None:
|
|
550
|
+
"""Start the world in one of its scenarios: the base seed with the scenario's
|
|
551
|
+
``seed/*.json`` overlays replacing whole entities, the way the platform's runner
|
|
552
|
+
does. A compiled scenario snapshot is opened as it is; otherwise the overlay is
|
|
553
|
+
applied on a fresh default."""
|
|
554
|
+
scenarios = self.scenarios()
|
|
555
|
+
if name not in scenarios:
|
|
556
|
+
fail(
|
|
557
|
+
"scenario",
|
|
558
|
+
f"No scenario {name}; the world has {', '.join(scenarios) or 'none'}",
|
|
559
|
+
f"/{SCENARIOS}/{name}",
|
|
560
|
+
)
|
|
561
|
+
built = snapshots.scenario_snapshot(self.world, name)
|
|
562
|
+
if self.snapshot and snapshots.current(
|
|
563
|
+
built,
|
|
564
|
+
snapshots.contract_hash(self.contract),
|
|
565
|
+
snapshots.scenario_batch_digest(scenarios[name], snapshots.expected_default_digest(self.world)),
|
|
566
|
+
):
|
|
567
|
+
with self._lock:
|
|
568
|
+
self._open(built)
|
|
569
|
+
return
|
|
570
|
+
overlay = snapshots.scenario_overlay(scenarios[name])
|
|
571
|
+
if self.snapshot:
|
|
572
|
+
self.seed({"base": self.digest, "rows": overlay, "mode": "replace"})
|
|
573
|
+
return
|
|
574
|
+
base: dict[str, list] = {}
|
|
575
|
+
for mutation in self.initial.get("mutations", []):
|
|
576
|
+
if mutation.get("op") == "insert":
|
|
577
|
+
base.setdefault(mutation["entity"], []).append(mutation["value"])
|
|
578
|
+
base.update(overlay)
|
|
579
|
+
self.seed(base)
|
|
580
|
+
|
|
581
|
+
def rows(self, entity: str) -> list[dict]:
|
|
582
|
+
with self._lock:
|
|
583
|
+
assert self.store is not None
|
|
584
|
+
return list(self.store.list(entity))
|
|
585
|
+
|
|
586
|
+
def get(self, entity: str, key: dict) -> dict | None:
|
|
587
|
+
with self._lock:
|
|
588
|
+
assert self.store is not None
|
|
589
|
+
return self.store.get(entity, key)
|
|
590
|
+
|
|
591
|
+
def insert(self, entity: str, value: dict) -> dict:
|
|
592
|
+
"""Persist a row the API created; the contract validates it like any ingest."""
|
|
593
|
+
with self._lock:
|
|
594
|
+
assert self.store is not None
|
|
595
|
+
return self.store.insert(entity, value, source={"record": "api.create"})
|
|
596
|
+
|
|
597
|
+
def update(self, entity: str, key: dict, patch: dict) -> dict:
|
|
598
|
+
with self._lock:
|
|
599
|
+
assert self.store is not None
|
|
600
|
+
return self.store.update(entity, key, patch, source={"record": "api.update"})
|
|
601
|
+
|
|
602
|
+
def delete(self, entity: str, key: dict) -> None:
|
|
603
|
+
with self._lock:
|
|
604
|
+
assert self.store is not None
|
|
605
|
+
self.store.delete(entity, key, source={"record": "api.delete"})
|
|
606
|
+
|
|
607
|
+
def key_of(self, entity: str, row: dict) -> dict:
|
|
608
|
+
return {name: row.get(name) for name in self.contract.entities[entity]["primary_key"]}
|
|
609
|
+
|
|
610
|
+
def dump(self) -> dict:
|
|
611
|
+
out: dict[str, dict[str, dict]] = {}
|
|
612
|
+
for entity, spec in self.contract.entities.items():
|
|
613
|
+
keyed = {}
|
|
614
|
+
for row in self.rows(entity):
|
|
615
|
+
key = ":".join(str(row.get(field)) for field in spec["primary_key"])
|
|
616
|
+
keyed[key] = row
|
|
617
|
+
out[entity] = keyed
|
|
618
|
+
return out
|
|
619
|
+
|
|
620
|
+
def describe_snapshot(self) -> dict | None:
|
|
621
|
+
"""What ``/__world/state`` reports beside the rows: the snapshot in use, or null."""
|
|
622
|
+
if not self.snapshot:
|
|
623
|
+
return None
|
|
624
|
+
return {"batchDigest": self.snapshot["batchDigest"], "contractHash": self.snapshot["contractHash"]}
|
|
625
|
+
|
|
626
|
+
def close(self) -> None:
|
|
627
|
+
with self._lock:
|
|
628
|
+
if self.store is not None:
|
|
629
|
+
self.store.close()
|
|
630
|
+
self.store = None
|
|
631
|
+
self._dir.cleanup()
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# ---------------------------------------------------------------- routing
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _dig(row: Any, path: str) -> Any:
|
|
638
|
+
for part in path.split("."):
|
|
639
|
+
if not isinstance(row, dict) or part not in row:
|
|
640
|
+
return None
|
|
641
|
+
row = row[part]
|
|
642
|
+
return row
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _matches(row: dict, filters: dict[str, str], values: dict[str, str], modes: dict[str, str] | None = None) -> bool:
|
|
646
|
+
"""Every filter whose argument was supplied must match; a parameter left out does not narrow.
|
|
647
|
+
|
|
648
|
+
``modes`` picks how: ``equals`` (default), ``contains`` (case-insensitive substring,
|
|
649
|
+
the vendor's ``icontains``) or ``prefix``. A list field matches when any element does."""
|
|
650
|
+
for arg, path in filters.items():
|
|
651
|
+
if arg not in values:
|
|
652
|
+
continue
|
|
653
|
+
value = _dig(row, path)
|
|
654
|
+
if value is None:
|
|
655
|
+
return False
|
|
656
|
+
mode = (modes or {}).get(arg, "equals")
|
|
657
|
+
wanted = values[arg].lower()
|
|
658
|
+
candidates = value if isinstance(value, list) else [value]
|
|
659
|
+
if not any(_compare(str(candidate).lower(), wanted, mode) for candidate in candidates):
|
|
660
|
+
return False
|
|
661
|
+
return True
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _compare(have: str, wanted: str, mode: str) -> bool:
|
|
665
|
+
if mode == "contains":
|
|
666
|
+
return wanted in have
|
|
667
|
+
if mode == "prefix":
|
|
668
|
+
return have.startswith(wanted)
|
|
669
|
+
return have == wanted
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
class Router:
|
|
673
|
+
def __init__(self, spec: Spec, settings: ApiSettings):
|
|
674
|
+
self.spec = spec
|
|
675
|
+
self.settings = settings
|
|
676
|
+
self.routes = [(operation, self._pattern(operation)) for operation in spec.operations.values()]
|
|
677
|
+
self.tokens: set[str] = set()
|
|
678
|
+
#: refresh token -> the subject it was minted for (password / refresh grants)
|
|
679
|
+
self._refresh_tokens: dict[str, str] = {}
|
|
680
|
+
self._jwt_secret = secrets.token_bytes(32)
|
|
681
|
+
self._lock = threading.Lock()
|
|
682
|
+
|
|
683
|
+
@staticmethod
|
|
684
|
+
def _pattern(operation: Operation) -> re.Pattern:
|
|
685
|
+
escaped = re.escape(operation.path)
|
|
686
|
+
pattern = re.sub(r"\\\{([A-Za-z_][A-Za-z0-9_]*)\\\}", r"(?P<\1>[^/]+)", escaped)
|
|
687
|
+
return re.compile(f"^{pattern}$")
|
|
688
|
+
|
|
689
|
+
def match(self, method: str, path: str, query: dict[str, str]) -> tuple[Operation, dict[str, str]] | None:
|
|
690
|
+
"""The operation for a request. Vendors reuse one path for several lookups that
|
|
691
|
+
differ only by query parameter, so among path matches the one whose declared
|
|
692
|
+
parameters the caller actually sent wins; ties keep declaration order."""
|
|
693
|
+
best: tuple[int, int, Operation, dict[str, str]] | None = None
|
|
694
|
+
for index, (operation, pattern) in enumerate(self.routes):
|
|
695
|
+
found = pattern.match(path)
|
|
696
|
+
if not found or operation.method != method:
|
|
697
|
+
continue
|
|
698
|
+
present = sum(1 for name in operation.params if name in query)
|
|
699
|
+
candidate = (present, -index, operation, {k: urllib.parse.unquote(v) for k, v in found.groupdict().items()})
|
|
700
|
+
if best is None or candidate[:2] > best[:2]:
|
|
701
|
+
best = candidate
|
|
702
|
+
return None if best is None else (best[2], best[3])
|
|
703
|
+
|
|
704
|
+
def issue_token(self, form: dict[str, str], key: str | None) -> dict | None:
|
|
705
|
+
"""The vendor's token endpoint. Three grants, the way real vendors mint:
|
|
706
|
+
|
|
707
|
+
* ``client_credentials`` (the default): a bearer ``access_token``.
|
|
708
|
+
* ``password``: ``email`` + ``password`` in, an ``id_token`` (an HS256 JWT
|
|
709
|
+
carrying ``sub``/``iat``/``exp``, so a client that reads expiry from the
|
|
710
|
+
token itself sees a real one) plus a ``refresh_token``. This is how
|
|
711
|
+
Torchlight's clients authenticate.
|
|
712
|
+
* ``refresh_token``: a refresh token this world minted in, a fresh
|
|
713
|
+
``id_token`` + ``refresh_token`` out; an unknown refresh token is refused.
|
|
714
|
+
|
|
715
|
+
With ``WORLD_API_KEY`` pinned the client secret (or the password) must be that
|
|
716
|
+
key, the way a real vendor knows its clients; unpinned, any grant is served.
|
|
717
|
+
Every bearer this world minted — access or id token — authorizes calls."""
|
|
718
|
+
grant = form.get("grant_type", "client_credentials")
|
|
719
|
+
if grant == "client_credentials":
|
|
720
|
+
if key is not None and form.get("client_secret") != key:
|
|
721
|
+
return None
|
|
722
|
+
token = secrets.token_urlsafe(32)
|
|
723
|
+
with self._lock:
|
|
724
|
+
self.tokens.add(token)
|
|
725
|
+
answer = {"access_token": token, "token_type": "Bearer", "expires_in": 3600}
|
|
726
|
+
if form.get("scope"):
|
|
727
|
+
answer["scope"] = form["scope"]
|
|
728
|
+
elif self.spec.scope:
|
|
729
|
+
answer["scope"] = self.spec.scope
|
|
730
|
+
return answer
|
|
731
|
+
if grant == "password":
|
|
732
|
+
subject = form.get("email") or form.get("username") or ""
|
|
733
|
+
if not subject or not form.get("password"):
|
|
734
|
+
return None
|
|
735
|
+
if key is not None and form.get("password") != key:
|
|
736
|
+
return None
|
|
737
|
+
return self._issue_identity(subject)
|
|
738
|
+
if grant == "refresh_token":
|
|
739
|
+
presented = form.get("refresh_token") or ""
|
|
740
|
+
with self._lock:
|
|
741
|
+
subject = self._refresh_tokens.pop(presented, None)
|
|
742
|
+
if subject is None:
|
|
743
|
+
return None
|
|
744
|
+
return self._issue_identity(subject)
|
|
745
|
+
return None
|
|
746
|
+
|
|
747
|
+
def _issue_identity(self, subject: str) -> dict:
|
|
748
|
+
"""An ``id_token`` (HS256 JWT, one hour) and a ``refresh_token``, both
|
|
749
|
+
remembered so later calls and refreshes are honoured."""
|
|
750
|
+
import hmac
|
|
751
|
+
import hashlib
|
|
752
|
+
|
|
753
|
+
now = int(time.time())
|
|
754
|
+
header = base64.urlsafe_b64encode(b'{"alg":"HS256","typ":"JWT"}').rstrip(b"=").decode()
|
|
755
|
+
payload = base64.urlsafe_b64encode(
|
|
756
|
+
json.dumps(
|
|
757
|
+
{"sub": subject, "iat": now, "exp": now + 3600, "iss": self.spec.slug, "jti": secrets.token_urlsafe(9)},
|
|
758
|
+
separators=(",", ":"),
|
|
759
|
+
).encode()
|
|
760
|
+
).rstrip(b"=").decode()
|
|
761
|
+
signature = base64.urlsafe_b64encode(
|
|
762
|
+
hmac.new(self._jwt_secret, f"{header}.{payload}".encode(), hashlib.sha256).digest()
|
|
763
|
+
).rstrip(b"=").decode()
|
|
764
|
+
id_token = f"{header}.{payload}.{signature}"
|
|
765
|
+
refresh = secrets.token_urlsafe(32)
|
|
766
|
+
with self._lock:
|
|
767
|
+
self.tokens.add(id_token)
|
|
768
|
+
self._refresh_tokens[refresh] = subject
|
|
769
|
+
return {
|
|
770
|
+
"id_token": id_token,
|
|
771
|
+
"access_token": id_token,
|
|
772
|
+
"refresh_token": refresh,
|
|
773
|
+
"token_type": "Bearer",
|
|
774
|
+
"expires_in": 3600,
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
def authorized(self, headers: dict[str, str], query: dict[str, str], key: str | None) -> bool:
|
|
778
|
+
spec = self.spec
|
|
779
|
+
if spec.scheme == "none":
|
|
780
|
+
return True
|
|
781
|
+
presented: str | None = None
|
|
782
|
+
if spec.scheme == "header":
|
|
783
|
+
presented = headers.get((spec.auth_name or "").lower())
|
|
784
|
+
elif spec.scheme in ("bearer", "oauth2"):
|
|
785
|
+
value = headers.get("authorization", "")
|
|
786
|
+
presented = value[7:] if value.lower().startswith("bearer ") else None
|
|
787
|
+
elif spec.scheme == "basic":
|
|
788
|
+
value = headers.get("authorization", "")
|
|
789
|
+
try:
|
|
790
|
+
presented = base64.b64decode(value[6:]).decode() if value.lower().startswith("basic ") else None
|
|
791
|
+
except (ValueError, UnicodeDecodeError):
|
|
792
|
+
presented = None
|
|
793
|
+
elif spec.scheme == "query":
|
|
794
|
+
presented = query.get(spec.auth_name or "")
|
|
795
|
+
if not presented:
|
|
796
|
+
return False
|
|
797
|
+
if spec.scheme == "oauth2":
|
|
798
|
+
# A bearer is good only if this world minted it: the client did the grant first.
|
|
799
|
+
with self._lock:
|
|
800
|
+
return presented in self.tokens
|
|
801
|
+
return key is None or presented == key
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ---------------------------------------------------------------- handlers
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
class ApiContext:
|
|
808
|
+
"""What a world's handler gets: the store, the clock, and the vendor's shapes."""
|
|
809
|
+
|
|
810
|
+
def __init__(
|
|
811
|
+
self, state: WorldState, router: Router, operation: Operation, link: tuple[str, dict[str, str]] | None = None
|
|
812
|
+
):
|
|
813
|
+
self.state = state
|
|
814
|
+
self.router = router
|
|
815
|
+
self.settings = router.settings
|
|
816
|
+
self.operation = operation
|
|
817
|
+
self.spec = router.spec
|
|
818
|
+
#: (absolute URL without query, query) of the request being answered, for page links.
|
|
819
|
+
self.link = link
|
|
820
|
+
#: The request's headers, lower-cased — a gate reads the credential from here.
|
|
821
|
+
self.headers: dict[str, str] = {}
|
|
822
|
+
|
|
823
|
+
# -- the store
|
|
824
|
+
def rows(self, entity: str) -> list[dict]:
|
|
825
|
+
return self.state.rows(entity)
|
|
826
|
+
|
|
827
|
+
def get(self, entity: str, key: dict) -> dict | None:
|
|
828
|
+
return self.state.get(entity, key)
|
|
829
|
+
|
|
830
|
+
def find(self, entity: str, **fields: Any) -> list[dict]:
|
|
831
|
+
wanted = {k: str(v) for k, v in fields.items()}
|
|
832
|
+
return [row for row in self.state.rows(entity) if _matches(row, {k: k for k in wanted}, wanted)]
|
|
833
|
+
|
|
834
|
+
def insert(self, entity: str, value: dict) -> dict:
|
|
835
|
+
return self.state.insert(entity, value)
|
|
836
|
+
|
|
837
|
+
def update(self, entity: str, key: dict, patch: dict) -> dict:
|
|
838
|
+
return self.state.update(entity, key, patch)
|
|
839
|
+
|
|
840
|
+
def delete(self, entity: str, key: dict) -> None:
|
|
841
|
+
self.state.delete(entity, key)
|
|
842
|
+
|
|
843
|
+
def key_of(self, entity: str, row: dict) -> dict:
|
|
844
|
+
return self.state.key_of(entity, row)
|
|
845
|
+
|
|
846
|
+
# -- time and identity
|
|
847
|
+
@staticmethod
|
|
848
|
+
def clock() -> float:
|
|
849
|
+
frozen = os.environ.get(CLOCK_ENV)
|
|
850
|
+
return float(frozen) if frozen else time.time()
|
|
851
|
+
|
|
852
|
+
def now(self) -> str:
|
|
853
|
+
return datetime.fromtimestamp(self.clock(), timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
854
|
+
|
|
855
|
+
def elapsed(self, since: str | None) -> float:
|
|
856
|
+
"""Seconds since an ISO timestamp the world recorded; infinite when it never happened."""
|
|
857
|
+
if not since:
|
|
858
|
+
return float("inf")
|
|
859
|
+
started = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
860
|
+
return self.clock() - started.timestamp()
|
|
861
|
+
|
|
862
|
+
def ladder(self, since: str | None, steps: list[tuple[float, Any]], before: Any = None) -> Any:
|
|
863
|
+
"""A status machine on a clock: the last step whose delay has passed.
|
|
864
|
+
|
|
865
|
+
``ladder(executed_at, [(0, "queued"), (5, "started"), (20, "completed")])``
|
|
866
|
+
reads ``queued`` right after execute, ``started`` five seconds later, and
|
|
867
|
+
``completed`` from twenty seconds on. ``before`` is the answer when nothing
|
|
868
|
+
has started yet."""
|
|
869
|
+
if not since:
|
|
870
|
+
return before
|
|
871
|
+
passed = self.elapsed(since)
|
|
872
|
+
current = before
|
|
873
|
+
for delay, value in steps:
|
|
874
|
+
if passed >= delay:
|
|
875
|
+
current = value
|
|
876
|
+
return current
|
|
877
|
+
|
|
878
|
+
@staticmethod
|
|
879
|
+
def uuid() -> str:
|
|
880
|
+
return str(uuid.uuid4())
|
|
881
|
+
|
|
882
|
+
# -- the vendor's shapes
|
|
883
|
+
def envelope(self, rows: list[dict], query: dict[str, str] | None = None, *, envelope: str | None = None) -> Any:
|
|
884
|
+
page = paginate(rows, query or {}, self.settings.paging)
|
|
885
|
+
return wrap(page, self.settings, envelope, link=self.link)
|
|
886
|
+
|
|
887
|
+
def paged(
|
|
888
|
+
self, rows: list[dict], query: dict[str, str] | None = None, *, envelope: str | None = None, status: int = 200
|
|
889
|
+
) -> Any:
|
|
890
|
+
"""``envelope`` plus the vendor's paging headers, as a handler's full answer."""
|
|
891
|
+
page = paginate(rows, query or {}, self.settings.paging)
|
|
892
|
+
return status, wrap(page, self.settings, envelope, link=self.link), page_headers(page, self.settings)
|
|
893
|
+
|
|
894
|
+
def not_found(self) -> tuple[int, Any]:
|
|
895
|
+
return self.settings.not_found_status, self.settings.not_found
|
|
896
|
+
|
|
897
|
+
def invalid(self, detail: str | None = None) -> tuple[int, Any]:
|
|
898
|
+
body = self.settings.invalid
|
|
899
|
+
if detail and isinstance(body, dict):
|
|
900
|
+
body = {**body, "detail": detail}
|
|
901
|
+
return self.settings.invalid_status, body
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
Handler = Callable[[ApiContext, dict[str, str], dict[str, str], Any], Any]
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def load_handlers(settings: ApiSettings) -> dict[str, Handler]:
|
|
908
|
+
"""Import the world's handlers module once; every ``action = "handler"`` name must exist."""
|
|
909
|
+
if settings.handlers is None:
|
|
910
|
+
return {}
|
|
911
|
+
module_name = f"gateway_world_api_handlers_{uuid.uuid4().hex}"
|
|
912
|
+
spec = importlib.util.spec_from_file_location(module_name, settings.handlers)
|
|
913
|
+
if spec is None or spec.loader is None:
|
|
914
|
+
fail("handlers", f"Could not load {settings.handlers}", "/api/handlers")
|
|
915
|
+
module = importlib.util.module_from_spec(spec)
|
|
916
|
+
sys.modules[module_name] = module
|
|
917
|
+
try:
|
|
918
|
+
spec.loader.exec_module(module)
|
|
919
|
+
finally:
|
|
920
|
+
sys.modules.pop(module_name, None)
|
|
921
|
+
handlers: dict[str, Handler] = {}
|
|
922
|
+
if settings.before:
|
|
923
|
+
gate = getattr(module, settings.before, None)
|
|
924
|
+
if not callable(gate):
|
|
925
|
+
fail(
|
|
926
|
+
"handlers",
|
|
927
|
+
f"{settings.handlers.name} has no function {settings.before} for [api].before",
|
|
928
|
+
"/api/before",
|
|
929
|
+
)
|
|
930
|
+
handlers[BEFORE] = gate
|
|
931
|
+
for name, op in settings.operations.items():
|
|
932
|
+
if not op.handler:
|
|
933
|
+
continue
|
|
934
|
+
function = getattr(module, op.handler, None)
|
|
935
|
+
if not callable(function):
|
|
936
|
+
fail(
|
|
937
|
+
"handlers",
|
|
938
|
+
f"{settings.handlers.name} has no function {op.handler} for operation {name}",
|
|
939
|
+
"/api/handlers",
|
|
940
|
+
)
|
|
941
|
+
handlers[name] = function
|
|
942
|
+
return handlers
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# ---------------------------------------------------------------- pages and envelopes
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
@dataclass(frozen=True)
|
|
949
|
+
class Page:
|
|
950
|
+
rows: list[dict]
|
|
951
|
+
total: int
|
|
952
|
+
offset: int
|
|
953
|
+
limit: int
|
|
954
|
+
style: str
|
|
955
|
+
|
|
956
|
+
@property
|
|
957
|
+
def next_offset(self) -> int | None:
|
|
958
|
+
following = self.offset + self.limit
|
|
959
|
+
return following if self.style != "none" and following < self.total else None
|
|
960
|
+
|
|
961
|
+
@property
|
|
962
|
+
def previous_offset(self) -> int | None:
|
|
963
|
+
return max(self.offset - self.limit, 0) if self.style != "none" and self.offset > 0 else None
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _encode_cursor(offset: int) -> str:
|
|
967
|
+
return base64.urlsafe_b64encode(str(offset).encode()).decode().rstrip("=")
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def paginate(rows: list[dict], query: dict[str, str], paging: Paging) -> Page:
|
|
971
|
+
"""One page of rows with the offsets the next and previous pages start at."""
|
|
972
|
+
total = len(rows)
|
|
973
|
+
if paging.style == "none":
|
|
974
|
+
return Page(rows, total, 0, total, "none")
|
|
975
|
+
limit = _int_param(query.get(paging.limit_param), paging.page_size)
|
|
976
|
+
offset = 0
|
|
977
|
+
if paging.style == "offset":
|
|
978
|
+
offset = _int_param(query.get(paging.offset_param), 0)
|
|
979
|
+
else:
|
|
980
|
+
raw = query.get(paging.cursor_param)
|
|
981
|
+
if raw:
|
|
982
|
+
try:
|
|
983
|
+
offset = int(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)).decode())
|
|
984
|
+
except (ValueError, UnicodeDecodeError):
|
|
985
|
+
offset = 0
|
|
986
|
+
return Page(rows[offset : offset + limit], total, offset, limit, paging.style)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _int_param(value: str | None, default: int) -> int:
|
|
990
|
+
try:
|
|
991
|
+
parsed = int(value) if value is not None else default
|
|
992
|
+
except ValueError:
|
|
993
|
+
return default
|
|
994
|
+
return parsed if parsed >= 0 else default
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _page_ref(page: Page, offset: int | None, paging: Paging, link: tuple[str, dict[str, str]] | None) -> Any:
|
|
998
|
+
"""What the vendor's client is handed for a neighbouring page: '' or None when there is
|
|
999
|
+
none, a cursor token, or — with ``page_links`` — the absolute URL DRF-style vendors answer."""
|
|
1000
|
+
if offset is None:
|
|
1001
|
+
return None if link is not None else ""
|
|
1002
|
+
if link is None:
|
|
1003
|
+
return _encode_cursor(offset)
|
|
1004
|
+
base, query = link
|
|
1005
|
+
following = dict(query)
|
|
1006
|
+
if page.style == "offset":
|
|
1007
|
+
following[paging.offset_param] = str(offset)
|
|
1008
|
+
else:
|
|
1009
|
+
following[paging.cursor_param] = _encode_cursor(offset)
|
|
1010
|
+
following[paging.limit_param] = str(page.limit)
|
|
1011
|
+
return f"{base}?{urllib.parse.urlencode(following)}"
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def page_headers(page: Page, settings: ApiSettings, paging: Paging | None = None) -> dict[str, str] | None:
|
|
1015
|
+
"""The page's cursor/total/previous as response headers, when the vendor sends them that way."""
|
|
1016
|
+
if not settings.page_headers:
|
|
1017
|
+
return None
|
|
1018
|
+
paging = paging or settings.paging
|
|
1019
|
+
values = {
|
|
1020
|
+
"cursor": _page_ref(page, page.next_offset, paging, None),
|
|
1021
|
+
"total": str(page.total),
|
|
1022
|
+
"previous": _page_ref(page, page.previous_offset, paging, None),
|
|
1023
|
+
}
|
|
1024
|
+
return {header: str(values[key]) for key, header in settings.page_headers.items() if values[key] not in ("", None)}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def wrap(
|
|
1028
|
+
page: Page | list[dict],
|
|
1029
|
+
settings: ApiSettings,
|
|
1030
|
+
envelope: str | None = None,
|
|
1031
|
+
*,
|
|
1032
|
+
paging: Paging | None = None,
|
|
1033
|
+
link: tuple[str, dict[str, str]] | None = None,
|
|
1034
|
+
) -> Any:
|
|
1035
|
+
"""Rows in the vendor's envelope: ``{<envelope>: rows, <hits_key>: total, <cursor_key>: next
|
|
1036
|
+
[, <previous_key>: previous]}``; an empty envelope key means the bare array."""
|
|
1037
|
+
if not isinstance(page, Page):
|
|
1038
|
+
page = Page(page, len(page), 0, len(page), "none")
|
|
1039
|
+
key = settings.envelope if envelope is None else envelope
|
|
1040
|
+
if not key:
|
|
1041
|
+
return page.rows
|
|
1042
|
+
paging = paging or settings.paging
|
|
1043
|
+
link = link if settings.page_links else None
|
|
1044
|
+
out = {
|
|
1045
|
+
key: page.rows,
|
|
1046
|
+
settings.hits_key: page.total,
|
|
1047
|
+
settings.cursor_key: _page_ref(page, page.next_offset, paging, link),
|
|
1048
|
+
}
|
|
1049
|
+
if settings.previous_key:
|
|
1050
|
+
out[settings.previous_key] = _page_ref(page, page.previous_offset, paging, link)
|
|
1051
|
+
return out
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _normalize(result: Any, default_status: int | None) -> Any:
|
|
1055
|
+
"""What a handler (or the gate) hands back, as ``(status, body, headers)``.
|
|
1056
|
+
|
|
1057
|
+
A handler returns a body, ``(status, body)`` or ``(status, body, headers)``;
|
|
1058
|
+
the gate returns ``None`` to let the request through or one of those to stop it."""
|
|
1059
|
+
if result is None and default_status is None:
|
|
1060
|
+
return None
|
|
1061
|
+
if (
|
|
1062
|
+
isinstance(result, tuple)
|
|
1063
|
+
and len(result) in (2, 3)
|
|
1064
|
+
and isinstance(result[0], int)
|
|
1065
|
+
and not isinstance(result[0], bool)
|
|
1066
|
+
):
|
|
1067
|
+
status, body = result[0], result[1]
|
|
1068
|
+
extra = result[2] if len(result) == 3 else None
|
|
1069
|
+
if extra is not None and not (
|
|
1070
|
+
isinstance(extra, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in extra.items())
|
|
1071
|
+
):
|
|
1072
|
+
fail("handlers", "A handler's headers are a mapping of header names to strings", "/api/handlers")
|
|
1073
|
+
return status, body, extra
|
|
1074
|
+
return default_status, result, None
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# ---------------------------------------------------------------- writes
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _set(row: dict, target: str, value: Any, current: dict | None) -> None:
|
|
1081
|
+
"""Assign a dotted target (``attributes.note``), keeping the rest of a nested
|
|
1082
|
+
object an update leaves alone."""
|
|
1083
|
+
parts = target.split(".")
|
|
1084
|
+
if len(parts) == 1:
|
|
1085
|
+
row[target] = value
|
|
1086
|
+
return
|
|
1087
|
+
head = parts[0]
|
|
1088
|
+
if head not in row:
|
|
1089
|
+
base = current.get(head) if current else None
|
|
1090
|
+
row[head] = json.loads(json.dumps(base)) if isinstance(base, dict) else {}
|
|
1091
|
+
node = row[head]
|
|
1092
|
+
for part in parts[1:-1]:
|
|
1093
|
+
if not isinstance(node.get(part), dict):
|
|
1094
|
+
node[part] = {}
|
|
1095
|
+
node = node[part]
|
|
1096
|
+
node[parts[-1]] = value
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _bind(op: OperationSettings, ctx: ApiContext, body: Any, current: dict | None = None) -> dict:
|
|
1100
|
+
"""The row a create or update writes: defaults, then the body (mapped or as-is), then generated fields."""
|
|
1101
|
+
if body is None or not isinstance(body, dict):
|
|
1102
|
+
body = {}
|
|
1103
|
+
row: dict = json.loads(json.dumps(op.defaults)) if current is None else {}
|
|
1104
|
+
if op.body:
|
|
1105
|
+
for field_name, path in op.body.items():
|
|
1106
|
+
value = _dig(body, path)
|
|
1107
|
+
if value is not None:
|
|
1108
|
+
_set(row, field_name, value, current)
|
|
1109
|
+
else:
|
|
1110
|
+
row.update(body)
|
|
1111
|
+
if current is None:
|
|
1112
|
+
for field_name, kind in op.generate.items():
|
|
1113
|
+
if row.get(field_name) is not None:
|
|
1114
|
+
continue
|
|
1115
|
+
if kind == "uuid":
|
|
1116
|
+
row[field_name] = ctx.uuid()
|
|
1117
|
+
elif kind == "now":
|
|
1118
|
+
row[field_name] = ctx.now()
|
|
1119
|
+
else:
|
|
1120
|
+
existing = [r.get(field_name) for r in ctx.rows(ctx.operation.entity or "")]
|
|
1121
|
+
row[field_name] = max([v for v in existing if isinstance(v, int)] + [0]) + 1
|
|
1122
|
+
return row
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
# ---------------------------------------------------------------- the server
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
Response = tuple[int, Any, dict[str, str] | None]
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
class WorldApp:
|
|
1132
|
+
"""One world's HTTP behaviour as a function of the request.
|
|
1133
|
+
|
|
1134
|
+
``handle`` takes what a server read off the wire (method, request target,
|
|
1135
|
+
headers, raw body) and answers ``(status, body, extra headers)``; nothing in
|
|
1136
|
+
it touches a socket. ``make_handler`` wraps it for ``serve()``, and the
|
|
1137
|
+
World Host calls it for whichever session a request names, so one process
|
|
1138
|
+
can answer for many worlds without the handler knowing.
|
|
1139
|
+
"""
|
|
1140
|
+
|
|
1141
|
+
def __init__(
|
|
1142
|
+
self,
|
|
1143
|
+
state: WorldState,
|
|
1144
|
+
router: Router,
|
|
1145
|
+
key: str | None,
|
|
1146
|
+
handlers: dict[str, Handler] | None = None,
|
|
1147
|
+
allowed: frozenset[str] | None = None,
|
|
1148
|
+
):
|
|
1149
|
+
self.state = state
|
|
1150
|
+
self.router = router
|
|
1151
|
+
self.key = key
|
|
1152
|
+
self.settings = router.settings
|
|
1153
|
+
self.prefix = urllib.parse.urlsplit(router.spec.base_url).path.rstrip("/")
|
|
1154
|
+
self.handlers = load_handlers(self.settings) if handlers is None else handlers
|
|
1155
|
+
#: The operations this session's task allows, or None for all of them.
|
|
1156
|
+
#: A route outside the list answers 403 before any handler runs, so the
|
|
1157
|
+
#: list holds against a client that talks to the vendor API directly.
|
|
1158
|
+
self.allowed = allowed
|
|
1159
|
+
|
|
1160
|
+
# ------------------------------------------------------------ helpers
|
|
1161
|
+
|
|
1162
|
+
@staticmethod
|
|
1163
|
+
def _json(raw: bytes) -> Any:
|
|
1164
|
+
if not raw:
|
|
1165
|
+
return {}
|
|
1166
|
+
try:
|
|
1167
|
+
return json.loads(raw.decode("utf-8"))
|
|
1168
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
1169
|
+
return None
|
|
1170
|
+
|
|
1171
|
+
@staticmethod
|
|
1172
|
+
def _form(raw: bytes, headers: dict[str, str]) -> dict[str, str]:
|
|
1173
|
+
content_type = (headers.get("content-type") or "").split(";")[0].strip().lower()
|
|
1174
|
+
if content_type == "application/json":
|
|
1175
|
+
try:
|
|
1176
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
1177
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
1178
|
+
return {}
|
|
1179
|
+
return {k: str(v) for k, v in parsed.items()} if isinstance(parsed, dict) else {}
|
|
1180
|
+
form = {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode("utf-8", "replace")).items()}
|
|
1181
|
+
basic = headers.get("authorization", "")
|
|
1182
|
+
if basic.lower().startswith("basic "):
|
|
1183
|
+
try:
|
|
1184
|
+
client_id, _, client_secret = base64.b64decode(basic[6:]).decode().partition(":")
|
|
1185
|
+
form.setdefault("client_id", client_id)
|
|
1186
|
+
form.setdefault("client_secret", client_secret)
|
|
1187
|
+
except (ValueError, UnicodeDecodeError):
|
|
1188
|
+
pass
|
|
1189
|
+
return form
|
|
1190
|
+
|
|
1191
|
+
def _strip(self, path: str) -> str:
|
|
1192
|
+
if self.prefix and path.startswith(self.prefix):
|
|
1193
|
+
return path[len(self.prefix) :] or "/"
|
|
1194
|
+
return path
|
|
1195
|
+
|
|
1196
|
+
# ------------------------------------------------------------ the request
|
|
1197
|
+
|
|
1198
|
+
def handle(self, method: str, target: str, headers: dict[str, str], raw: bytes, default_host: str) -> Response:
|
|
1199
|
+
"""Answer one request. ``headers`` are lower-cased; ``default_host`` is what
|
|
1200
|
+
a link uses when the request carries no Host header."""
|
|
1201
|
+
url = urllib.parse.urlsplit(target)
|
|
1202
|
+
settings, router, state = self.settings, self.router, self.state
|
|
1203
|
+
if method == "GET":
|
|
1204
|
+
if url.path == ADMIN["health"]:
|
|
1205
|
+
return 200, {"ok": True, "world": router.spec.slug}, None
|
|
1206
|
+
if url.path == ADMIN["state"]:
|
|
1207
|
+
return 200, {**state.dump(), "snapshot": state.describe_snapshot()}, None
|
|
1208
|
+
if method == "POST":
|
|
1209
|
+
if url.path == ADMIN["reset"]:
|
|
1210
|
+
state.reset()
|
|
1211
|
+
return 200, {"ok": True}, None
|
|
1212
|
+
if url.path == ADMIN["seed"]:
|
|
1213
|
+
body = self._json(raw)
|
|
1214
|
+
if body is None:
|
|
1215
|
+
return 400, {"error": "seed must be JSON"}, None
|
|
1216
|
+
try:
|
|
1217
|
+
state.seed(body)
|
|
1218
|
+
except ContractError as error:
|
|
1219
|
+
if error.detail.get("code") == "base_mismatch":
|
|
1220
|
+
return 409, {"error": "base_mismatch", "expected": error.detail.get("expected")}, None
|
|
1221
|
+
return 400, {"error": "seed_rejected", "detail": str(error)}, None
|
|
1222
|
+
except Exception as error: # a rejected seed is the caller's problem, reported plainly
|
|
1223
|
+
return 400, {"error": "seed_rejected", "detail": str(error)}, None
|
|
1224
|
+
return 200, {"ok": True}, None
|
|
1225
|
+
if settings.token_path and self._strip(url.path) == settings.token_path:
|
|
1226
|
+
issued = router.issue_token(self._form(raw, headers), self.key)
|
|
1227
|
+
if issued is None:
|
|
1228
|
+
return 400, {"error": "invalid_client"}, None
|
|
1229
|
+
return 200, issued, None
|
|
1230
|
+
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
|
1231
|
+
return 405, {"error": "method_not_allowed"}, None
|
|
1232
|
+
return self._route(method, url, headers, raw, default_host)
|
|
1233
|
+
|
|
1234
|
+
def _route(self, method: str, url, headers: dict[str, str], raw: bytes, default_host: str) -> Response:
|
|
1235
|
+
settings, router, state, handlers = self.settings, self.router, self.state, self.handlers
|
|
1236
|
+
path = self._strip(url.path)
|
|
1237
|
+
query = {k: v[0] for k, v in urllib.parse.parse_qs(url.query).items()}
|
|
1238
|
+
if not router.authorized(headers, query, self.key):
|
|
1239
|
+
return settings.unauthorized_status, settings.unauthorized, None
|
|
1240
|
+
matched = router.match(method, path, query)
|
|
1241
|
+
if matched is None:
|
|
1242
|
+
return settings.not_found_status, settings.not_found, None
|
|
1243
|
+
operation, args = matched
|
|
1244
|
+
if self.allowed is not None and operation.name not in self.allowed:
|
|
1245
|
+
return 403, {"error": "tool_not_allowed", "tool": operation.name}, None
|
|
1246
|
+
op = settings.for_operation(operation.name)
|
|
1247
|
+
body = self._json(raw) if method in ("POST", "PUT", "PATCH") else {}
|
|
1248
|
+
if body is None:
|
|
1249
|
+
return settings.invalid_status, settings.invalid, None
|
|
1250
|
+
host = headers.get("host") or default_host
|
|
1251
|
+
forwarded = (headers.get("x-forwarded-proto") or "http").split(",")[0].strip()
|
|
1252
|
+
ctx = ApiContext(state, router, operation, link=(f"{forwarded}://{host}{url.path}", query))
|
|
1253
|
+
ctx.headers = headers
|
|
1254
|
+
try:
|
|
1255
|
+
if BEFORE in handlers:
|
|
1256
|
+
# The world's gate: quotas, allow-lists, key states — whatever the vendor
|
|
1257
|
+
# checks before any route, declarative ones included.
|
|
1258
|
+
verdict = _normalize(handlers[BEFORE](ctx, args, query, body), None)
|
|
1259
|
+
if verdict is not None:
|
|
1260
|
+
return verdict
|
|
1261
|
+
status, answer, extra = _normalize(self._answer(op, operation, ctx, args, query, body, handlers), op.status)
|
|
1262
|
+
except ContractError as error:
|
|
1263
|
+
detail = getattr(error, "detail", None) or {"message": str(error)}
|
|
1264
|
+
invalid = settings.invalid
|
|
1265
|
+
if isinstance(invalid, dict):
|
|
1266
|
+
invalid = {**invalid, "detail": detail}
|
|
1267
|
+
return settings.invalid_status, invalid, None
|
|
1268
|
+
return status, answer, extra
|
|
1269
|
+
|
|
1270
|
+
@staticmethod
|
|
1271
|
+
def _answer(
|
|
1272
|
+
op: OperationSettings,
|
|
1273
|
+
operation: Operation,
|
|
1274
|
+
ctx: ApiContext,
|
|
1275
|
+
args: dict[str, str],
|
|
1276
|
+
query: dict[str, str],
|
|
1277
|
+
body: Any,
|
|
1278
|
+
handlers: dict[str, Handler],
|
|
1279
|
+
) -> Any:
|
|
1280
|
+
if op.action == "handler":
|
|
1281
|
+
return handlers[operation.name](ctx, args, query, body)
|
|
1282
|
+
if not operation.entity:
|
|
1283
|
+
return 501, {"error": "not_modelled", "operation": operation.name}
|
|
1284
|
+
entity = operation.entity
|
|
1285
|
+
values = {**{k: v for k, v in query.items() if k in operation.params}, **args}
|
|
1286
|
+
if op.action == "create":
|
|
1287
|
+
row = ctx.insert(entity, _bind(op, ctx, body))
|
|
1288
|
+
return op.status, row if op.single else wrap([row], ctx.settings, op.envelope)
|
|
1289
|
+
rows = [row for row in ctx.rows(entity) if _matches(row, op.filter, values, op.match)]
|
|
1290
|
+
if op.action in ("update", "delete"):
|
|
1291
|
+
if len(rows) != 1:
|
|
1292
|
+
return ctx.not_found()
|
|
1293
|
+
key = ctx.key_of(entity, rows[0])
|
|
1294
|
+
if op.action == "delete":
|
|
1295
|
+
ctx.delete(entity, key)
|
|
1296
|
+
return op.status, None
|
|
1297
|
+
row = ctx.update(entity, key, _bind(op, ctx, body, current=rows[0]))
|
|
1298
|
+
return op.status, row if op.single else wrap([row], ctx.settings, op.envelope)
|
|
1299
|
+
if op.single:
|
|
1300
|
+
if not rows:
|
|
1301
|
+
return ctx.not_found()
|
|
1302
|
+
return op.status, rows[0]
|
|
1303
|
+
paging = (
|
|
1304
|
+
ctx.settings.paging
|
|
1305
|
+
if op.paging is None
|
|
1306
|
+
else Paging(
|
|
1307
|
+
op.paging,
|
|
1308
|
+
ctx.settings.paging.limit_param,
|
|
1309
|
+
ctx.settings.paging.offset_param,
|
|
1310
|
+
ctx.settings.paging.cursor_param,
|
|
1311
|
+
ctx.settings.paging.page_size,
|
|
1312
|
+
)
|
|
1313
|
+
)
|
|
1314
|
+
page = paginate(rows, query, paging)
|
|
1315
|
+
return (
|
|
1316
|
+
op.status,
|
|
1317
|
+
wrap(page, ctx.settings, op.envelope, paging=paging, link=ctx.link),
|
|
1318
|
+
page_headers(page, ctx.settings, paging),
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def send_response(
|
|
1323
|
+
handler: BaseHTTPRequestHandler, status: int, body: Any, extra: dict[str, str] | None, error_content_type: str
|
|
1324
|
+
) -> None:
|
|
1325
|
+
"""Write one ``WorldApp`` answer to a request handler's socket."""
|
|
1326
|
+
if status == 204 or body is None:
|
|
1327
|
+
handler.send_response(status)
|
|
1328
|
+
for name, value in (extra or {}).items():
|
|
1329
|
+
handler.send_header(name, value)
|
|
1330
|
+
handler.send_header("Content-Length", "0")
|
|
1331
|
+
handler.end_headers()
|
|
1332
|
+
return
|
|
1333
|
+
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
1334
|
+
handler.send_response(status)
|
|
1335
|
+
handler.send_header("Content-Type", error_content_type if status >= 400 else "application/json")
|
|
1336
|
+
for name, value in (extra or {}).items():
|
|
1337
|
+
handler.send_header(name, value)
|
|
1338
|
+
handler.send_header("Content-Length", str(len(payload)))
|
|
1339
|
+
handler.end_headers()
|
|
1340
|
+
handler.wfile.write(payload)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def make_handler(state: WorldState, router: Router, key: str | None):
|
|
1344
|
+
app = WorldApp(state, router, key)
|
|
1345
|
+
|
|
1346
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1347
|
+
server_version = f"gateway-world-api/{router.spec.slug}"
|
|
1348
|
+
|
|
1349
|
+
def log_message(self, *_):
|
|
1350
|
+
pass
|
|
1351
|
+
|
|
1352
|
+
def _serve(self, method: str) -> None:
|
|
1353
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
1354
|
+
raw = self.rfile.read(length) if length else b""
|
|
1355
|
+
headers = {k.lower(): v for k, v in self.headers.items()}
|
|
1356
|
+
status, body, extra = app.handle(method, self.path, headers, raw, f"127.0.0.1:{self.server.server_port}")
|
|
1357
|
+
send_response(self, status, body, extra, app.settings.error_content_type)
|
|
1358
|
+
|
|
1359
|
+
def do_GET(self):
|
|
1360
|
+
self._serve("GET")
|
|
1361
|
+
|
|
1362
|
+
def do_POST(self):
|
|
1363
|
+
self._serve("POST")
|
|
1364
|
+
|
|
1365
|
+
def do_PUT(self):
|
|
1366
|
+
self._serve("PUT")
|
|
1367
|
+
|
|
1368
|
+
def do_PATCH(self):
|
|
1369
|
+
self._serve("PATCH")
|
|
1370
|
+
|
|
1371
|
+
def do_DELETE(self):
|
|
1372
|
+
self._serve("DELETE")
|
|
1373
|
+
|
|
1374
|
+
return Handler
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def serve(
|
|
1378
|
+
world: str | Path,
|
|
1379
|
+
*,
|
|
1380
|
+
port: int = PORT,
|
|
1381
|
+
host: str = "0.0.0.0",
|
|
1382
|
+
api_key: str | None = None,
|
|
1383
|
+
scenario: str | None = None,
|
|
1384
|
+
) -> ThreadingHTTPServer:
|
|
1385
|
+
"""Start the world's API and return the server; the caller decides how long it runs."""
|
|
1386
|
+
root = Path(world)
|
|
1387
|
+
spec = load_spec(root)
|
|
1388
|
+
settings = load_api_settings(root, spec)
|
|
1389
|
+
state = WorldState(root)
|
|
1390
|
+
if scenario:
|
|
1391
|
+
state.apply_scenario(scenario)
|
|
1392
|
+
server = ThreadingHTTPServer((host, port), make_handler(state, Router(spec, settings), api_key))
|
|
1393
|
+
server.world_state = state # type: ignore[attr-defined]
|
|
1394
|
+
return server
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def fetch_hosted_snapshot(world: str | Path, environ: dict[str, str] | None = None) -> dict | None:
|
|
1398
|
+
"""The hosted pod's snapshot, when ``WORLD_SNAPSHOT_URL`` / ``_SHA256`` / ``_DIGEST``
|
|
1399
|
+
are set: downloaded to ``db/state.sqlite``, verified, sidecar written. All three
|
|
1400
|
+
are required together; a digest mismatch is fatal and names the digest."""
|
|
1401
|
+
env = os.environ if environ is None else environ
|
|
1402
|
+
values = {name: env.get(name) for name in (snapshots.ENV_URL, snapshots.ENV_SHA256, snapshots.ENV_DIGEST)}
|
|
1403
|
+
if not any(values.values()):
|
|
1404
|
+
return None
|
|
1405
|
+
missing = [name for name, value in values.items() if not value]
|
|
1406
|
+
if missing:
|
|
1407
|
+
fail("snapshot", f"{', '.join(missing)} must be set together with the snapshot URL")
|
|
1408
|
+
root = Path(world)
|
|
1409
|
+
return snapshots.fetch(
|
|
1410
|
+
root,
|
|
1411
|
+
check_bundle(root),
|
|
1412
|
+
values[snapshots.ENV_URL],
|
|
1413
|
+
values[snapshots.ENV_SHA256],
|
|
1414
|
+
values[snapshots.ENV_DIGEST],
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def serve_forever(
|
|
1419
|
+
world: str | Path, *, port: int = PORT, api_key: str | None = None, scenario: str | None = None
|
|
1420
|
+
) -> None:
|
|
1421
|
+
try:
|
|
1422
|
+
fetched = fetch_hosted_snapshot(world)
|
|
1423
|
+
except ContractError as error:
|
|
1424
|
+
print(f"world snapshot refused: {error}", file=sys.stderr)
|
|
1425
|
+
raise SystemExit(2)
|
|
1426
|
+
if fetched:
|
|
1427
|
+
print(f"world snapshot {fetched['batchDigest']} opened", file=sys.stderr)
|
|
1428
|
+
server = serve(world, port=port, api_key=api_key, scenario=scenario)
|
|
1429
|
+
try:
|
|
1430
|
+
print(f"world api listening on http://0.0.0.0:{port}", file=sys.stderr)
|
|
1431
|
+
server.serve_forever()
|
|
1432
|
+
finally:
|
|
1433
|
+
server.server_close()
|
|
1434
|
+
server.world_state.close() # type: ignore[attr-defined]
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
# ---------------------------------------------------------------- bundle
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def openapi_document(spec: Spec, contract_id: str, settings: ApiSettings | None = None) -> dict:
|
|
1441
|
+
parameters = {"header": "header", "query": "query"}
|
|
1442
|
+
security_scheme: dict[str, Any] = {}
|
|
1443
|
+
if spec.scheme in ("header", "query"):
|
|
1444
|
+
security_scheme = {"type": "apiKey", "in": parameters[spec.scheme], "name": spec.auth_name}
|
|
1445
|
+
elif spec.scheme == "bearer":
|
|
1446
|
+
security_scheme = {"type": "http", "scheme": "bearer"}
|
|
1447
|
+
elif spec.scheme == "basic":
|
|
1448
|
+
security_scheme = {"type": "http", "scheme": "basic"}
|
|
1449
|
+
elif spec.scheme == "oauth2":
|
|
1450
|
+
token_url = (settings.token_path if settings else None) or DEFAULT_TOKEN_PATH
|
|
1451
|
+
security_scheme = {
|
|
1452
|
+
"type": "oauth2",
|
|
1453
|
+
"flows": {"clientCredentials": {"tokenUrl": token_url, "scopes": {spec.scope or "api": "API access"}}},
|
|
1454
|
+
}
|
|
1455
|
+
paths: dict[str, dict] = {}
|
|
1456
|
+
for operation in spec.operations.values():
|
|
1457
|
+
op = settings.for_operation(operation.name) if settings else OperationSettings()
|
|
1458
|
+
params = [
|
|
1459
|
+
{"name": name, "in": "path", "required": True, "schema": {"type": "string"}}
|
|
1460
|
+
for name in operation.placeholders
|
|
1461
|
+
] + [
|
|
1462
|
+
{"name": name, "in": "query", "required": False, "schema": {"type": "string"}} for name in operation.params
|
|
1463
|
+
]
|
|
1464
|
+
entry: dict[str, Any] = {
|
|
1465
|
+
"operationId": operation.name,
|
|
1466
|
+
"parameters": params,
|
|
1467
|
+
**({"x-gateway-entity": operation.entity} if operation.entity else {}),
|
|
1468
|
+
"x-gateway-action": op.action,
|
|
1469
|
+
"responses": {
|
|
1470
|
+
str(op.status): {"description": "OK"},
|
|
1471
|
+
"401": {"description": "Unauthorized"},
|
|
1472
|
+
"404": {"description": "Not found"},
|
|
1473
|
+
},
|
|
1474
|
+
**({"security": [{"vendor": []}]} if security_scheme else {}),
|
|
1475
|
+
}
|
|
1476
|
+
if op.action in ("create", "update") or (
|
|
1477
|
+
op.action == "handler" and operation.method in ("POST", "PUT", "PATCH")
|
|
1478
|
+
):
|
|
1479
|
+
entry["requestBody"] = {"content": {"application/json": {"schema": {"type": "object"}}}}
|
|
1480
|
+
methods = paths.setdefault(operation.path, {})
|
|
1481
|
+
method = operation.method.lower()
|
|
1482
|
+
if method in methods:
|
|
1483
|
+
# OpenAPI keys one operation per path+method; a vendor that tells lookups apart
|
|
1484
|
+
# by query parameter keeps every one of them here rather than losing all but the first.
|
|
1485
|
+
methods[method].setdefault("x-gateway-variants", []).append(
|
|
1486
|
+
{
|
|
1487
|
+
k: v
|
|
1488
|
+
for k, v in entry.items()
|
|
1489
|
+
if k in ("operationId", "parameters", "x-gateway-entity", "x-gateway-action")
|
|
1490
|
+
}
|
|
1491
|
+
)
|
|
1492
|
+
else:
|
|
1493
|
+
methods[method] = entry
|
|
1494
|
+
document: dict[str, Any] = {
|
|
1495
|
+
"openapi": "3.1.0",
|
|
1496
|
+
"info": {"title": spec.slug, "version": "1.0.0", "x-gateway-domain": contract_id},
|
|
1497
|
+
"servers": [{"url": spec.base_url}],
|
|
1498
|
+
"paths": paths,
|
|
1499
|
+
}
|
|
1500
|
+
if security_scheme:
|
|
1501
|
+
document["components"] = {"securitySchemes": {"vendor": security_scheme}}
|
|
1502
|
+
return document
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _scenario_dirs(root: Path) -> dict[str, Path]:
|
|
1506
|
+
"""A world's authored scenarios: each a starting state (seed overlays replacing whole
|
|
1507
|
+
entities) plus an instruction, so a session can open the world mid-investigation,
|
|
1508
|
+
quota-exhausted, or whatever else the vendor's client should be tested against."""
|
|
1509
|
+
return snapshots.scenario_dirs(root)
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def base_seed(initial: dict) -> dict[str, list]:
|
|
1513
|
+
"""``seed/base.json``: the initial batch's inserted rows by entity, what older
|
|
1514
|
+
runners post as the base of a session's seed."""
|
|
1515
|
+
seed: dict[str, list] = {}
|
|
1516
|
+
for mutation in initial.get("mutations", []):
|
|
1517
|
+
if mutation.get("op") == "insert":
|
|
1518
|
+
seed.setdefault(mutation["entity"], []).append(mutation["value"])
|
|
1519
|
+
return seed
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def write_base_seed(world: Path, bundle: Path) -> Path:
|
|
1523
|
+
"""Regenerate the bundle's ``seed/base.json`` from the world's ``data/initial.json``."""
|
|
1524
|
+
target = bundle / "seed/base.json"
|
|
1525
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1526
|
+
target.write_text(json.dumps(base_seed(read_json(world / "data/initial.json")), indent=1) + "\n")
|
|
1527
|
+
return target
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def build_api_bundle(world: str | Path, out: str | Path) -> Path:
|
|
1531
|
+
"""Write the api-kind bundle that hosts this world's API under ``out``.
|
|
1532
|
+
|
|
1533
|
+
The world stays the authority; this is packaging. `gateway bench push` calls it
|
|
1534
|
+
for any schema world carrying connector.toml, so users push the world, not a
|
|
1535
|
+
second artifact."""
|
|
1536
|
+
root = Path(world)
|
|
1537
|
+
out_dir = Path(out)
|
|
1538
|
+
if out_dir.exists() and any(out_dir.iterdir()):
|
|
1539
|
+
fail("api", "Output directory must be empty or absent", "/api/out")
|
|
1540
|
+
spec = load_spec(root)
|
|
1541
|
+
settings = load_api_settings(root, spec)
|
|
1542
|
+
load_handlers(settings) # a handler the module lacks fails here, not in the container
|
|
1543
|
+
contract = compile_bundle(root)
|
|
1544
|
+
check_bundle(root)
|
|
1545
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1546
|
+
manifest = [
|
|
1547
|
+
"# Generated from the world under server/world, which is the authority.",
|
|
1548
|
+
"[service]",
|
|
1549
|
+
f'name = "{contract.metadata["package"]["id"]}"',
|
|
1550
|
+
f'emulates = "{spec.slug}"',
|
|
1551
|
+
"",
|
|
1552
|
+
"[server]",
|
|
1553
|
+
f"port = {PORT}",
|
|
1554
|
+
f'healthcheck = "{ADMIN["health"]}"',
|
|
1555
|
+
"",
|
|
1556
|
+
"[admin]",
|
|
1557
|
+
f'reset = "{ADMIN["reset"]}"',
|
|
1558
|
+
f'seed = "{ADMIN["seed"]}"',
|
|
1559
|
+
f'state = "{ADMIN["state"]}"',
|
|
1560
|
+
"",
|
|
1561
|
+
]
|
|
1562
|
+
if settings.ui is not None:
|
|
1563
|
+
# The built ui travels inside the copied world; the host serves it from
|
|
1564
|
+
# there, so no ui image is ever built for a schema world.
|
|
1565
|
+
manifest += [
|
|
1566
|
+
"[ui]",
|
|
1567
|
+
f'static = "server/world/{settings.ui.static}"',
|
|
1568
|
+
f'entry = "{settings.ui.entry}"',
|
|
1569
|
+
f'api_path = "{settings.ui.api_path}"',
|
|
1570
|
+
"",
|
|
1571
|
+
]
|
|
1572
|
+
(out_dir / "gateway-api-world.toml").write_text("\n".join(manifest))
|
|
1573
|
+
(out_dir / "openapi.json").write_text(
|
|
1574
|
+
json.dumps(openapi_document(spec, contract.target["id"], settings), indent=1) + "\n"
|
|
1575
|
+
)
|
|
1576
|
+
(out_dir / "seed").mkdir()
|
|
1577
|
+
write_base_seed(root, out_dir)
|
|
1578
|
+
authored = _scenario_dirs(root)
|
|
1579
|
+
for name, path in authored.items():
|
|
1580
|
+
shutil.copytree(path, out_dir / "scenarios" / name)
|
|
1581
|
+
if "default" not in authored:
|
|
1582
|
+
scenario = out_dir / "scenarios/default"
|
|
1583
|
+
scenario.mkdir(parents=True)
|
|
1584
|
+
(scenario / "scenario.toml").write_text('name = "default"\nseed = "seed"\n')
|
|
1585
|
+
(scenario / "instruction.md").write_text(
|
|
1586
|
+
f"The {spec.slug} world serving the vendor's API, seeded with its captured state. Point the provider client here.\n"
|
|
1587
|
+
)
|
|
1588
|
+
server = out_dir / "server"
|
|
1589
|
+
package = server / "gatewaysdk" / "environment" / "schema"
|
|
1590
|
+
package.mkdir(parents=True)
|
|
1591
|
+
(server / "gatewaysdk" / "__init__.py").write_text("")
|
|
1592
|
+
(server / "gatewaysdk" / "environment" / "__init__.py").write_text("")
|
|
1593
|
+
for module in sorted(Path(__file__).parent.glob("*.py")):
|
|
1594
|
+
shutil.copy(module, package / module.name)
|
|
1595
|
+
shutil.copytree(root, server / "world", ignore=shutil.ignore_patterns("captures", "__pycache__", ".git"))
|
|
1596
|
+
(server / "main.py").write_text(
|
|
1597
|
+
"\n".join(
|
|
1598
|
+
[
|
|
1599
|
+
'"""Serve the world\'s API; everything it needs sits beside this file."""',
|
|
1600
|
+
"import os",
|
|
1601
|
+
"import sys",
|
|
1602
|
+
"from pathlib import Path",
|
|
1603
|
+
"",
|
|
1604
|
+
"sys.path.insert(0, str(Path(__file__).resolve().parent))",
|
|
1605
|
+
"from gatewaysdk.environment.schema.api import serve_forever # noqa: E402",
|
|
1606
|
+
"",
|
|
1607
|
+
"serve_forever(",
|
|
1608
|
+
' Path(__file__).resolve().parent / "world",',
|
|
1609
|
+
f' port=int(os.environ.get("PORT") or {PORT}),',
|
|
1610
|
+
' api_key=os.environ.get("WORLD_API_KEY") or None,',
|
|
1611
|
+
")",
|
|
1612
|
+
"",
|
|
1613
|
+
]
|
|
1614
|
+
)
|
|
1615
|
+
)
|
|
1616
|
+
(server / "Dockerfile").write_text(
|
|
1617
|
+
"\n".join(
|
|
1618
|
+
[
|
|
1619
|
+
"FROM python:3.12-slim",
|
|
1620
|
+
"WORKDIR /app",
|
|
1621
|
+
"COPY server /app",
|
|
1622
|
+
f"EXPOSE {PORT}",
|
|
1623
|
+
'CMD ["python", "/app/main.py"]',
|
|
1624
|
+
"",
|
|
1625
|
+
]
|
|
1626
|
+
)
|
|
1627
|
+
)
|
|
1628
|
+
return out_dir
|