Skip to content

Commit ad2d5b0

Browse files
anconguiAndrés Contreras Guillén
andauthored
fix: full-framework parity & wiring remediation (v26.06.01) (#26)
* fix(di): DI-core keystones from full-parity audit (Wave 0) Fixes audit findings that affect every bean lifecycle: - #113 interface-typed @bean was registered under both its concrete and return type, so @post_construct / AOP weaving / every BeanPostProcessor ran TWICE on the same instance. Startup lifecycle, wiring, and pre_destroy passes now de-duplicate by instance identity and propagate the woven instance to all aliases. - #114 synchronous @around advice was silently dropped; _build_sync_wrapper now builds a sync around chain mirroring the async one. - #115 a synchronous @app_event_listener crashed startup (await on None); ApplicationEventBus.publish awaits only awaitable results. - #116 TRANSIENT @bean factory methods were ignored on resolution (rebuilt via __init__); Registration now carries a factory closure that the container prefers. - #117 conditional_on_property gains match_if_missing and Spring-correct empty-having_value semantics (present-and-not-"false"). - #119 app-event-type inference no longer mistakes the return annotation for the event type. Full suite 3397 passed; ruff + mypy clean. * fix(web): post-start dynamic wiring keystone (Wave 0, audit #40/#163) create_app() builds the WebFilter chain and collects routes BEFORE ApplicationContext.start(), so beans only instantiated during startup were silently dropped: security/session/CSRF WebFilters never ran, and @bean-produced controllers (orchestration, IDP, config-server) were unreachable. Both the Starlette and FastAPI adapters now re-run filter discovery and controller/ws/sse/OAuth2-login route collection inside the wrapped lifespan, after start(): - WebFilterChainMiddleware holds the live filter list so in-place additions are seen per request. - Filter/route discovery is factored into re-runnable, identity/path deduplicated closures; FastAPIControllerRegistrar.register_controllers is now idempotent. - The rescan is exposed via app.state.pyfly_install_dynamic_wiring for callers managing their own lifespan. This unblocks the security (#41/#42/#43/#44), admin (#66/#70), orchestration-web (#163), config-server (#83) and CORS (#204) fixes. Full suite 3399 passed; ruff + mypy clean. * fix(transactional): workflow + TCC + scheduling parity (audit #53-65) Orchestration was largely inert because nothing wired the declared beans: - #53 @saga/@workflow/@tcc beans are now auto-discovered into their registries via a new OrchestrationBeanPostProcessor (mirrors Java ensureScanned) — previously every declared orchestration was dead. - #54 @scheduled_saga/@scheduled_workflow/@scheduled_tcc are turned into live ScheduledTasks and the OrchestrationScheduler (already started by the context lifecycle) now fires them; register() spins up loops post-start. - #55 wait_for_any no longer swallows timeouts and captures the winning signal payload. - #56 TCC participant retry is N+1 attempts (was off-by-one) and falls back to class-level @tcc(retry_enabled, max_retries, backoff_ms). - #57 per-participant try/confirm/cancel errors + latency are recorded so TccResult.failed_participants() works. - #58 @on_workflow_error(suppress_error/error_types/step_ids) downgrades FAILED→COMPLETED. - #59 @workflow_step(condition=...) is evaluated via a new safe AST expression evaluator (no eval); false → step SKIPPED. - #60 async_ steps are fire-and-forget; #62 fire-and-forget run tasks hold a strong reference so they aren't GC-cancelled. - #61 workflow-level retry propagates to steps that declare none. - #63 @WaitForAll/@WaitForAny support timers; #64 named timer_id surfaced in the timer event; #65 warns on a compensatable step with no comp method. Full suite 3411 passed; ruff + mypy clean. * fix(security): wire JWT/OAuth2/CSRF/HttpSecurity filters + session + OIDC (audit #41-52) The keystone (#40/#163) re-scans WebFilter beans post-start, but several filters had no bean at all and were dead code: - #41 SecurityFilter (symmetric JWT, opt-in via pyfly.security.jwt.filter.enabled) and OAuth2ResourceServerFilter are now registered as WebFilter beans by the security auto-configs that own their JWTService/JWKSTokenValidator (avoids cross-config bean ordering); the post-start rescan adds them to the chain. - #42 CsrfFilter gains a CsrfFilterAutoConfiguration (pyfly.security.csrf.enabled) and its order is corrected to run before authentication (was -50, after). - #45 @pre_authorize/@post_authorize now enforce once the auth filter populates RequestContext.security_context (integration test added). - #46 RedisSessionStore round-trips dataclass attributes (SecurityContext) via a type-tagged JSON encoder/decoder instead of failing on json.dumps. - #47 HttpSecurityFilterAutoConfiguration builds the URL-rule filter from a user HttpSecurity bean. - #48 OAuth2 login validates the OIDC id_token against the provider JWKS + nonce before trusting claims; #49 surfaces token/userinfo failures as 401 (was a silently-stored anonymous session) and wraps httpx transport errors. - #50 security expression evaluator accepts double-quoted args; #51 fixes the session-filter ordering docstring; #52 session cookie gains a configurable Secure flag + sliding re-issue. Full suite 3415 passed; ruff + mypy clean. * fix(client): error mapping, retry filtering, lifecycle, timeout (audit #12-15,#18) - #12 (critical) the declarative client never checked HTTP status and returned 4xx/5xx error payloads as success. New src/pyfly/client/exceptions.py adds a ServiceClientException hierarchy + map_http_error (400→Validation, 401/403→ Authentication, 404→NotFound, 409→Conflict, 422→Unprocessable, 429→RateLimit, 5xx→Unavailable); the generated impl raises on status >= 400. - #13 retry now retries only transient failures (429/5xx-mapped, connection, timeout) — not 4xx or the circuit-open signal — and the wrapping order is fixed to circuit-breaker OUTSIDE / retry INSIDE so an OPEN circuit fails fast. - #14 HttpClientBeanPostProcessor gains start()/stop(); the context lifecycle now closes every per-bean httpx client (was a connection-pool leak). - #15 the post-processor builds adapters with the configured pyfly.client.timeout (declarative clients previously ignored it). - #18 a declarative method's param is sent as request headers (for per-call headers / auth injection) instead of as a query param. Remaining client parity features (#16 OAuth2 client-credentials, #17 service discovery, #19 request dedup, #20 client rate limiting) are net-new subsystems tracked for a follow-up pass. Full suite 3419 passed; ruff + mypy clean. * fix(idp): REST controller, provider selection, adapter bugs (audit #22,#23,#25,#26,#28,#29) - #22 (critical) the IdpAdapter bean was wired to nothing — no HTTP surface existed. New src/pyfly/idp/web.py adds an IdpController (/idp/login, /refresh, /logout, /introspect, /admin/users CRUD, /admin/roles) registered in the same auto-config as the adapter and mounted by the post-start route rescan. - #25 provider selection: pyfly.idp.provider chooses internal-db | keycloak | cognito | azure-ad and builds the adapter from config (was hardwired to internal-db); the bean is now exposed under the IdpAdapter port type. - #23 Cognito login computes SECRET_HASH = Base64(HMAC-SHA256(secret, user+id)) when a client secret is configured (auth previously failed for secret clients). - #26 Keycloak admin token is refreshed on expiry (tracked via expires_in) — it was cached forever and broke after the ~60s TTL. - #28 Cognito login/refresh guard a missing AuthenticationResult and surface a clean PermissionError; refresh keeps a rotated refresh token. - #29 internal-db assign_role populates the role catalogue + adds create_roles (list_roles was always empty). Remaining idp parity (deferred): #24 port capability expansion (MFA/sessions/ scopes/userinfo/register), #27 Azure AD local token parsing + MFA. Full suite 3424 passed; ruff + mypy clean. * fix(notifications): wire real providers + delivery correctness (audit #30,#31,#35,#36,#38) - #30 (critical) auto-config only ever registered Dummy providers and ignored any provider-selection config. It now reads pyfly.notifications.{email,sms,push}.provider and builds the real adapter (SendGrid/Resend/SMTP, Twilio, Firebase) from config, falling back to Dummy; providers are exposed under their port types so the services inject the port. - #31 the SMTP adapter now attaches EmailMessage.attachments (was silently dropped); #35 it also adds custom EmailMessage.headers. - #36 the Default{Email,Sms,Push}Service convert a provider exception into a structured FAILED NotificationResult instead of propagating. - #38 ResendEmailProvider honors a configured default_from sender. Deferred (net-new subsystems): #32 template-email engine, #33 channel preferences, #34 notification metrics, #37 FCM batch. Full suite 3428 passed; ruff + mypy clean. * fix(cli): add PyFlyApplication.run() so the CLI archetype boots (audit #1) The generated `cli` archetype's main() does asyncio.run(pyfly.run()), but PyFlyApplication had no run() — every generated CLI crashed with AttributeError. run() now starts the app, resolves the ShellRunnerPort (populated with @shell_command handlers during startup), dispatches the args (or sys.argv[1:], falling back to an interactive REPL), and shuts down. Full suite 3431 passed; ruff + mypy clean. * fix(cache): serializer, putIfAbsent, prefix-evict, stats, health, null-cache (audit #72-80) - #72 a new cache serializer tolerates datetime/Decimal/UUID/set/Pydantic so the Redis put path no longer throws TypeError on framework types. - #75 put_if_absent added to the protocol + both adapters (Redis SET NX, in-memory check-then-set). - #78 evict_by_prefix added (Redis SCAN+delete, in-memory prefix filter). - #76 both adapters track hit/miss/eviction counters and report hit_rate. - #79 RedisCacheAdapter.start() degrades gracefully (logs + marks unavailable) instead of aborting application startup when Redis is unreachable. - #74 new CacheHealthIndicator (put/get/evict probe + latency) registered by the cache auto-config so /actuator/health reports cache status. - #80 @cacheable/@cache distinguish a cached None from a miss via exists() (null caching / cache-penetration protection). Deferred: #73 SmartCacheAdapter (L1/L2 read-through), #77 cache metrics, #81/#82 CacheManager protocol completeness. Full suite 3437 passed; ruff + mypy clean. * fix(cqrs): metrics registry, deterministic cache key, correlation, validation metric (audit #94,#98,#99,#100) - #94 cqrs_metrics_service now receives the observability MetricsRegistry via optional injection, so CQRS metrics are actually recorded (was permanently no-op). - #100 Query.get_cache_key() uses a stable SHA-256 digest instead of the process-randomized builtin hash(), so the same query maps to the same key across processes/restarts. - #98 the command bus restores the prior correlation id in a finally block so it no longer leaks into the next command on the same task. - #99 a CqrsValidationException now records the validation-failure metric. Deferred (involved wiring): #93 EventDrivenCacheInvalidator, #95 @publish_domain_event, #96 CqrsMetricsEndpoint, #97 publisher destination/headers. Full suite 3439 passed; ruff + mypy clean. * fix(scheduling/resilience): sync time-limiter, Spring cron, scheduled-failure logging (audit #184,#185,#186) - #184 the synchronous time_limiter ran the callable in a worker thread and waits with a fractional timeout instead of SIGALRM, which truncated sub-second timeouts to whole seconds and crashed off the main thread. - #185 CronExpression accepts Spring-style 6-field (seconds-first) cron and the '?' day placeholder (via croniter second_at_beginning + normalization), not just standard 5-field cron. - #186 _invoke logs exceptions, so a failing cron / fixed-rate iteration (whose task the loop does not await) is reported instead of vanishing silently. Full suite 3443 passed; ruff + mypy clean. * fix(validation): ISO-4217 currency, IBAN length, card-scheme checks (audit #191,#192,#193) - #191 is_valid_currency_code checks membership of the active ISO 4217 set (was: any 3 uppercase letters). - #193 is_valid_iban enforces the country-specific total length per ISO 13616 (was: general format + mod-97 only). - #192 is_valid_credit_card requires a recognized scheme prefix (Visa, MC, Amex, Discover, Diners, JCB) before the Luhn check (was: Luhn only). Full suite 3446 passed; ruff + mypy clean. * fix(callbacks): SSRF domain allowlist + retry only transient failures (audit #190,#194) - #190 the dispatcher enforces config.authorized_domains before delivery: a target host not matching the allowlist is marked FAILED ("Domain not authorized") and no HTTP request is made (SSRF protection). - #194 retries are limited to transient failures (5xx/408/429 + transport errors) with exponential backoff capped at 5 min; a 4xx client error is now permanent (was: retry every non-2xx with fixed backoff). Full suite 3448 passed; ruff + mypy clean. * fix(data): soft-delete read paths + connection-pool config (audit #103,#107) - #103 SoftDeleteRepository overrides find_paginated, find_all_by_ids, find_all_by_spec and find_all_by_spec_paged to apply the not-deleted predicate (incl. the page-total count) — these inherited base readers previously leaked soft-deleted rows and miscounted pagination totals. - #107 the SQLAlchemy engine forwards configured connection-pool settings (pool.size/max-overflow/timeout/recycle/pre-ping) to create_async_engine; only url and echo were read before. Full suite 3450 passed; ruff + mypy clean. * fix(eventsourcing): fail on unhandled events, replay validation, snapshot interval (audit #146,#150,#151) - #146 AggregateRoot._dispatch raises EventHandlerException when an event has no registered handler (or on_<type> method) instead of silently swallowing it and corrupting reconstructed state. - #150 EventSourcedRepository.load validates each replayed envelope's aggregate_id / aggregate_type against the loaded aggregate. - #151 a snapshot is taken when the appended batch CROSSES a multiple of the snapshot interval (was: exact 'version % interval == 0', which is missed when a batch straddles the threshold). Full suite 3451 passed; ruff + mypy clean. * fix(rule-engine): fail on unknown actions, isolate action failures, validate 'not' (audit #215,#216,#222) - #215 the default evaluator raises NotImplementedError for unsupported action types ('call'/'calculate'/typos) instead of silently doing nothing. - #216 each action runs in isolation: a failing action records its error on the EvaluationResult and the remaining actions still execute. - #222 the 'not' operator requires exactly one child; empty/multiple children raise instead of silently using only the first. Full suite passed; ruff + mypy clean. * chore: gitignore local .audit/ remediation working files * fix(eda): @event_listener discovery, publish timing, serializer/consumer config (audit #134,#138,#140,#141) - #134 @event_listener now stamps discovery metadata and supports a bus-less form (@event_listener(["user.*"])); a new ApplicationContext._wire_event_listeners pass auto-subscribes decorated methods to the EventPublisher bean during startup (mirrors @message_listener) — previously it only worked with a hand-wired bus. - #140 @event_publisher AFTER/BOTH publishes a payload augmented with the method result instead of re-publishing the pre-call arguments. - #138 the EDA auto-config selects the serializer from pyfly.eda.serialization-format (json|avro|protobuf) and passes it to the Kafka/Redis adapters (Avro/Protobuf remain opt-in stubs). - #141 the Redis adapter receives the configured consumer-id. Deferred (cross-adapter dispatch refactor): #131 DLQ routing, #132 ErrorStrategy, #133 circuit-breaker wiring, #135 postgres outbox stall, #136 event filters, #137 metrics, #139 health probe. Full suite 3457 passed; ruff + mypy clean. * fix(config): placeholder env overrides + config-client merge order (audit #86,#87,#89) - #86 ConfigClient applies remote propertySources in reverse (lowest priority first) so the highest-priority source wins, matching Spring (was: forward order, so the lowest-priority source won). - #87/#89 inline ${...} placeholder resolution now consults the PYFLY_* relaxed env mapping (e.g. ${app.name} → PYFLY_APP_NAME) before the raw file data, so env overrides are visible to placeholders and win. Full suite passed; ruff + mypy clean. * fix(di): @config_properties classes are injectable beans bound from config (audit #118) The @config_properties decorator now marks the class injectable (__pyfly_injectable__), so the scanner registers it; ApplicationContext binds each registered __pyfly_config_prefix__ class from the active Config via a factory, making it constructor-injectable by type (Spring @EnableConfigurationProperties equivalent). Previously these classes were only usable when a @bean method manually called config.bind(). Full suite green; ruff + mypy clean. * fix(saga): honor configured compensation_policy (audit #170; #178 already done) - #170 SagaEngineProperties is now bound from pyfly.transactional.saga.* (was built with defaults), SagaEngine stores a default_compensation_policy, and execute() falls back to it when a caller does not override — the configured YAML compensation_policy was previously inert (engine always STRICT_SEQUENTIAL). - #178 (scheduled sagas) was already addressed by the OrchestrationBeanPostProcessor added in b2d0f4e, which schedules @scheduled_saga beans. Deferred (compensator/orchestrator internals): #171 compensation-error abort, #172 idempotency keys, #173 recovery scheduling, #174-177, #179. Full suite 3461 passed; ruff + mypy clean. * fix(server/websocket): serve_async honors full config + ws on_disconnect (audit #226,#232) - #226 a shared UvicornServerAdapter._build_kwargs feeds both serve() (uvicorn.run) and serve_async() (uvicorn.Config), so the async path honors SSL / keep-alive / backlog / graceful-timeout / concurrency limits that the blocking path already did (they were previously dropped from serve_async). - #232 the websocket endpoint logs unexpected handler exceptions (no longer swallowed) and invokes an on_disconnect lifecycle hook when the controller defines one. Full suite passed; ruff + mypy clean. * fix(web): RFC-compliant required params, CORS auto-config, exception-converter wiring (audit #202/#204/#206) #206 — Missing required QueryParam/Header/Cookie now raises a 400 (InvalidRequestException) instead of silently binding None. A param is optional only when it has a default or an Optional inner type (str | None). Surfaced two latent transactional filter params that were meant to be optional; their inner types are now honestly str | None. #204 — CORS is auto-configured from pyfly.web.cors.* via CORSConfig.from_config(), threaded into create_app for both the Starlette and FastAPI adapters when no explicit CORSConfig is passed. Secure-by-default (disabled unless pyfly.web.cors.enabled), mirroring Spring CorsAutoConfiguration. #202 — ExceptionConverterService is now wired into global_exception_handler. Non-PyFly exceptions are translated via the built-in chain (Pydantic, JSON, timeout) plus any user @bean ExceptionConverter, discovered at startup and stashed per-app on app.state. Falls back to 500 only when nothing matches. Tests: tests/web/test_wave_web.py (14). Suite 3462 -> 3476 green; ruff + mypy clean. * fix(ecm): provider-based storage/e-signature adapter selection + honor delete result (audit #120/#125) #120 — EcmAutoConfiguration now selects the storage adapter from pyfly.ecm.storage.provider (local | s3/aws | azure) and the e-signature adapter from pyfly.ecm.esignature.provider (noop | docusign | adobe | logalty), reading each provider's own config sub-keys. All producer beans (storage, metadata, folders, e-signature) now return their PORT types so DocumentService / ESignatureService inject abstractions; the container registers each bean under both the concrete type and the port, so port-typed injection resolves. Previously the adapter classes existed but were dead — only LocalFilesystem + NoOp were ever wired regardless of config. #125 — DocumentService.delete now returns storage_ok AND metadata_ok instead of discarding the storage delete result, so a failed blob delete is surfaced. Tests: tests/ecm/test_wave_ecm.py (12) incl. a real-ApplicationContext wiring test that round-trips an upload/download through the auto-wired ports. Deferred (per-module): #121 DocumentVersionPort, #122 signature lifecycle/proof ports (net-new subsystems), #126 version refetch, #127/#128/#129 provider get() polish, #130 folder hierarchy. #124 (enabled-by-default) intentionally NOT flipped: every sibling optional module (cache/notifications/eventsourcing/ rule-engine/callbacks/idp) is uniformly opt-in via enabled=true; flipping only ECM would make it the lone default-on module — a deliberate pyfly convention. Suite 3476 -> 3488 green; ruff + mypy clean. * fix(observability,actuator): wire span exporter, correct threaddump className, guard prometheus endpoint (audit #153/#161/#162) #153 — TracingAutoConfiguration.tracer_provider now installs a BatchSpanProcessor with an exporter selected from config (pyfly.observability.tracing.exporter = otlp | console | none). When unset, OTLP is auto-selected iff an endpoint is configured (pyfly.observability.tracing.otlp.endpoint or the standard OTEL_EXPORTER_OTLP_ENDPOINT env var), with graceful fallback + a warning if the OTLP exporter package is missing; otherwise a single info line is logged so the drop is no longer silent. Previously the provider had no processor, so every @span trace was recorded and immediately discarded. #161 — threaddump now reports className as the frame's module (__name__) and methodName as the qualified function name (co_qualname), instead of setting both to the bare function name. Walks the frame chain so co_filename/co_name/module are read per frame. #162 — prometheus endpoint: enabled now reflects prometheus_client availability, and handle() returns a 503 text body instead of raising TypeError when the client is absent at request time. Tests: tests/observability/test_wave_observability.py (4), tests/actuator/test_wave_actuator.py (3). Deferred (observability wave-2): #154 trace/correlation IDs into log records, #155 W3C traceparent extraction into OTel context, #157 config-driven health groups, #158 firefly.health.status gauge, #159 common metric tags, #160 actuator beans unused by route builder. #156 (default web exposure incl. metrics/prometheus) intentionally NOT changed — pyfly deliberately mirrors Spring Boot's secure-by- default exposure (health,info), set in commit #25; auto-exposing metrics would be a security regression. Suite 3488 -> 3495 green; ruff + mypy clean. * fix(i18n,resilience): MessageFormat quote-escaping + unified bulkhead permit accounting (audit #187/#189) #187 — ResourceBundleMessageSource._substitute now honors java.text.MessageFormat quote semantics: '' renders as a literal apostrophe, single-quoted text is copied literally (so '{0}' is not substituted), and {n}/{n,type,style} insert the positional arg (index parsed; format style is a documented non-locale-applied subset). Previously naive str.replace left ''/quoted placeholders wrong. #189 — Bulkhead now uses a single threading.Lock-guarded permit counter as the sole source of truth for BOTH sync and async decorated calls (the divergent asyncio.Semaphore is removed). The sync wrapper now goes through the same _acquire_slot/_release_slot primitive as the async path, so a Bulkhead shared across sync + async can no longer desynchronise its capacity accounting; the atomic check-and-increment also closes the async check/acquire TOCTOU. Tests: tests/i18n/test_wave_i18n.py (8), tests/resilience/test_wave_resilience.py (4). #188 (i18n MessageSource opt-in vs Spring default-on) intentionally NOT changed — the finding itself says opt-in is acceptable if intentional; every sibling optional module is uniformly opt-in via enabled=true (same convention as #124/#156). Suite 3495 -> 3507 green; ruff + mypy clean. * fix(plugins): extension-point registration/validation + unload unregisters extensions (audit #218/#219) #218 — ExtensionRegistry now records extension points: register_extension_point, has_extension_point, extension_point_ids. PluginManager.add() scans inner @extension_point classes (the decorated class is the point's interface type) and registers them BEFORE extensions, so register() validates that each contributed extension is an instance of its declared point type and raises ValueError otherwise — mirroring Java's DefaultExtensionRegistry. Previously the @extension_point decorator's metadata was pure dead code, never read by anything. Registration against an id with no declared point type stays lenient (backward-compatible) rather than raising on unknown point. #219 — PluginManager now tracks the (point_id, instance) pairs each plugin registers and adds remove(plugin_id) + unload_all(): unloading a plugin runs its unload hook, unregisters its extensions from the registry, and forgets it, so extensions no longer leak in the registry for the process lifetime after a plugin is unloaded (mirrors Java's unloadPlugin -> unregisterPluginExtensions). Tests: tests/plugins/test_wave_plugins.py (8). #220 (rule-engine audit trail) deferred — net-new subsystem (AuditTrailService/ entity/repository/controller + evaluate-with-audit); out of scope for this pass, documented like other net-new subsystem gaps. Suite 3507 -> 3515 green; ruff + mypy clean. * fix(orchestration-web): DLQ /count endpoint + in-flight default for GET /executions (audit #167/#169) #167 — DeadLetterController now exposes GET /api/orchestration/dlq/count, backed by a new DeadLetterService.count() and DeadLetterStore.count() (InMemory impl returns len). Java's DeadLetterController exposes /count for dashboards; pyfly previously 404'd on it and had no service-level count(). #169 — OrchestrationController.list_executions now defaults to in-flight (non-terminal) executions when no status filter is given, instead of returning the entire store including terminal history — matching Java's findInFlight() default. Explicit ?status=<S> still filters exactly. Uses the existing ExecutionStatus.is_terminal. Tests: tests/transactional/test_wave_orchestration_web.py (4). Deferred (orchestration-web): #163 already done (wave-0 route rescan). #164 WorkflowController 2->14 endpoints + WorkflowEngine cancel/suspend/resume/ find_by_correlation_id/find_by_status (large engine+REST expansion), #165 per-controller rest.enabled/health.enabled gates, #166 orchestration-metrics actuator endpoint, #168 DLQ retry real re-execution (needs engine re-enqueue). Suite 3515 -> 3519 green; ruff + mypy clean. * fix(starters): correct property keys so bundled adapters actually activate (audit #2/#3/#4/#6) Starter stacks set property keys their auto-configs never read, so the bundled adapters/services silently never activated (the old keys had ZERO consumers). Fixed to the keys the conditions actually gate on: #2 data/domain: pyfly.relational.enabled -> pyfly.data.relational.enabled pyfly.document.enabled -> pyfly.data.document.enabled #3 core: pyfly.eda.enabled -> pyfly.eda.provider=auto (EdaAutoConfiguration gates on pyfly.eda.provider; 'auto' resolves to the best installed broker, falling back to the in-memory bus) #4 application: pyfly.security-jwt.enabled / pyfly.security-password.enabled / pyfly.session-filter.enabled -> pyfly.security.enabled=true (JWT auth WebFilter stays opt-in via pyfly.security.jwt.filter.enabled) #6 core/web: pyfly.actuator.enabled / pyfly.actuator.metrics.enabled -> pyfly.web.actuator.enabled Scaffold (pyfly.yaml.j2): added pyfly.security.enabled to the security feature block so generated security apps wire JWTService + the password encoder. Left the 'eda' feature's messaging: block as-is — pyfly.messaging.provider drives the SEPARATE MessagingAutoConfiguration (MessageBrokerPort), a real distinct subsystem, and is covered by an existing test; it is not the EDA EventPublisher path. Updated the existing test_starters.py assertions (old keys) in lockstep. Tests: tests/starters/test_wave_starter_keys.py (8, functionally proving each auto-config's conditions now pass via ConditionEvaluator with deps installed) + 1 scaffold security test. Deferred: #5 test-slice decorators (net-new), #10 testcontainers (net-new), #11 inert pyfly.web/observability/metrics/tracing/resilience/aop.enabled keys (deliberate convention: those auto-configs activate on @conditional_on_class, not property flags). Suite 3519 -> 3528 green; ruff + mypy clean. * fix(testing,cli,shell): inject mock_bean, thread wizard package name, honor shell_option type/choices (audit #7/#8/#9) #7 — PyFlyTestCase.setup() now registers every mock_bean(...) descriptor's AsyncMock into the context container keyed on its bean type, so DI-resolved collaborators receive the mock. Previously the descriptors produced a mock on the test instance but it was never wired into the ApplicationContext, so context.get_bean(T) returned the real/absent bean. #8 — generate_project()/_build_context() accept an explicit package_name; the interactive 'pyfly new' wizard's collected package name is now threaded through instead of being discarded and recomputed from the project name. #9 — @shell_option(type=...) / @shell_argument(type=...) overrides are now honored by param inference (param_type = explicit type or inferred inner_type), and shell_option gained a choices= kwarg that flows onto the resolved ShellParam. Tests: tests/testing/test_wave_mock_inject.py (2), tests/cli/test_wave_new_package.py (4), tests/shell/test_wave_shell_option.py (4). Suite 3528 -> 3538 green; ruff + mypy clean. * fix(config-server): full overlay propertySources + configurable persistent backend root (audit #85/#88) #85 — ConfigServer.fetch now emits the standard Spring-Cloud-Config overlay set (highest priority first): {app}/{profile}, {app}/default, application/{profile}, application/default — deduped, skipping absent overlays, returning None only when all are absent. Previously it returned a single propertySource, so an app's default bundle and shared application config were never delivered. #88 — ConfigServerAutoConfiguration.config_backend now reads pyfly.config-server.backend.root (or .native.search-locations) and persists there, falling back to a throwaway tempdir only when nothing is configured. Previously every boot used a fresh mkdtemp() that served nothing and lost saves on restart. The bean now injects Config (same pattern as the actuator beans). Tests: tests/config_server/test_wave_config_server.py (7). Existing test_server_returns_property_sources stays green (single source -> [0] unchanged). Suite 3538 -> 3545 green; ruff + mypy clean. * fix(config): relaxed kebab/snake key + placeholder resolution, env-only key injection (audit #90/#92) #92 — _raw_get() and ${...} config-reference resolution now use relaxed (kebab/snake interchangeable) segment matching via a new _dict_get_relaxed helper, with an exact-match fast path first. So ${my-prop.sub-key} resolves a value stored under my_prop.sub_key (and vice versa), matching bind()'s relaxed semantics; previously get()/placeholders required literal key segments. #90 — effective_section()/bind() now inject env-only keys: a PYFLY_<PREFIX>_* environment variable with no corresponding file leaf is materialized into the section (scoped to the prefix, treating each '_' as a path separator, only adding absent leaves). So an env-only @config_properties field now binds, where before get() honored the env var but bind()/effective_section dropped it. The rootless ('') prefix is skipped (ambiguous without a section anchor), so the broad effective_dict() actuator/admin view is unchanged. Tests: tests/core/test_wave_config_relaxed.py (9). Full suite green across the broad _raw_get change. Suite 3545 -> 3554 green; ruff + mypy clean. * fix(config-server): mount ConfigServer HTTP routes (audit #83) The ConfigServer bean was built but never reachable over HTTP, so a config server served nothing. Added a Starlette adapter (config_server/adapters/starlette.py, the hexagonal starlette boundary) exposing Spring-Cloud-Config routes (GET/POST /{application}/{profile}[/{label}], GET /_list), and a config_server/wiring.py build_config_server_routes(context) that discovers the ConfigServer bean and mounts them when pyfly.config-server.enabled=true. Because the bean is only instantiated during ApplicationContext.start() (after create_app returns), the routes are added in BOTH the eager create_app path (for an already-started context) AND the post-start _install_dynamic_wiring rescan, deduped by route key — same keystone pattern as orchestration/admin controllers. Mirrored into the FastAPI adapter's _install_context_routes. base_path is read from pyfly.config-server.base-path (default root). Tests: tests/config_server/test_wave_config_server.py — adapter round-trip + routes-mounted-when-enabled (real ApplicationContext + TestClient) + absent-when- disabled. Hexagonal isolation test stays green (starlette only in the adapter). Suite 3554 -> 3557 green; ruff + mypy clean. * fix(config): import remote config from a config server at bootstrap (audit #84) PyFlyApplication.__init__ now invokes ConfigClient when pyfly.cloud.config.uri (or pyfly.config.import) is set, fetching remote config from a Spring-Cloud-Config server and deep-merging it as a HIGH-precedence source over local config. The flat dotted propertySource map is expanded to nested via _merge_dotted; the source is recorded in loaded_sources. Runs before the logging adapter/context exist (uses a stdlib logger). Fully optional and non-fatal by default — a missing httpx, unreachable server, or non-200 logs a warning and falls back to local config — unless pyfly.cloud.config.fail-fast=true. Guards against asyncio.run inside an already- running loop (skips with a warning). No-op (early return) when no uri is set, so every existing app is unaffected. Tests: tests/config_server/test_wave_remote_config.py (4, sync so asyncio.run works): merged-at-bootstrap, no-uri-skips, failure-non-fatal, fail-fast-propagates. config-server module now fully remediated (#83/#84/#85/#86/#87/#88/#89/#90/#92). Suite 3557 -> 3561 green; ruff + mypy clean. * fix(admin): enforce require_auth, TRACE/OFF log levels, mount beans SSE (audit #66/#69/#71) #66 — AdminRouteBuilder now wraps every {base}/api/* route (data, mutation, SSE, instance registry) with an auth guard: when pyfly.admin.require-auth is true, a request lacking an authenticated SecurityContext gets 401 and one missing every allowed role gets 403 (read from the request-scoped RequestContext). The SPA shell + static assets stay public so the dashboard can boot and then surface the 401s. No-op when require_auth is false (default), so existing behavior is unchanged. Routes are now built from a guarded spec list. #69 — LoggersProvider.set_level resolves TRACE (->5, below DEBUG) and OFF (->CRITICAL+1, disables) explicitly; both were advertised as valid levels but getattr(logging, 'TRACE'/'OFF') is None, so selecting them silently returned 400. #71 — the beans SSE generator (admin/api/sse.py beans_stream) is now reachable via GET {base}/api/sse/beans; it existed but was never routed. Tests: tests/admin/test_wave_admin_auth.py (10). Suite admin 115 -> 125 green; ruff + mypy clean. * fix(admin): server-mode instance registry, client self-registration, health rescan (audit #67/#68/#70) #67 — create_app now binds AdminServerProperties and, when pyfly.admin.server.enabled=true, builds an InstanceRegistry, seeds it from the configured instances via StaticDiscovery, and passes it to AdminRouteBuilder — so the /api/instances routes mount and settings.serverMode reports true. Previously server mode was permanently off (registry never constructed). #68 — AdminClientRegistration gained start()/stop() lifecycle methods that honor auto_register (now threaded from AdminClientProperties), so the ApplicationContext infrastructure lifecycle self-registers the app with the remote admin server at startup and deregisters at shutdown. register() swallows its own errors, so a down admin server never aborts startup. #70 — when the actuator is disabled but admin is enabled, the admin-owned HealthAggregator now gets a post-start indicator rescan (appended to _extra_post_start), so the admin health view reflects HealthIndicator beans instantiated during start() instead of a frozen empty pre-startup snapshot. Tests: tests/admin/test_wave_admin_wiring.py (6). Admin module fully remediated (#66/#67/#68/#69/#70/#71). Suite 3561 -> 3577 green; ruff + mypy clean. * docs(release): v26.06.01 — refresh docs to match remediation, bump version - Bump version 26.06.00 -> 26.06.01 (pyproject 26.6.1, __version__, README badge). - CHANGELOG: comprehensive v26.06.01 entry covering the ~130-finding parity/wiring remediation (web required-params + CORS + exception converters; config relaxed binding + env-only keys + remote import; config-server HTTP routes/overlays/root; admin auth + server-mode + client registration + log levels + beans SSE; tracing exporter; starter property keys; ECM provider selection; plugin extension-point validation/unload; orchestration DLQ count + in-flight default; bulkhead permit accounting; i18n MessageFormat; shell option type/choices; mock_bean injection; pyfly new package name). - Docs refreshed against current code across web, configuration, config-server, admin, observability, actuator, ecm, plugins, resilience, transactional, validation, shell, testing, cli, error-handling, web-filters; new module guides for i18n and websocket; indexes updated. - fix(config): remote-config import reads pyfly.app.name (was the inconsistent pyfly.application.name), matching the framework-wide key. Suite 3577 green; ruff + mypy clean. * style: fix import ordering in 3 wave test files (ruff I001, CI lint) --------- Co-authored-by: Andrés Contreras Guillén <ancongui@Andress-MacBook-Pro.local>
1 parent 254e95e commit ad2d5b0

182 files changed

Lines changed: 8705 additions & 613 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ docs/plans/
2121

2222
# Playwright MCP verification artifacts
2323
.playwright-mcp/
24+
.audit/

CHANGELOG.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,117 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
---
88

9+
## v26.06.01 (2026-06-05)
10+
11+
### Full-framework parity & wiring remediation
12+
13+
A framework-wide audit against the Java Firefly Framework surfaced ~130 verified
14+
issues — mostly *noop-wiring* (a capability was built but never connected) and
15+
parity gaps. This release fixes them. Most changes are bug fixes or new
16+
auto-wiring; behavior changes that could affect existing apps are called out as
17+
**[behavior]**.
18+
19+
**Web**
20+
21+
- **[behavior]** A missing **required** `QueryParam`/`Header`/`Cookie` (no default
22+
and a non-`Optional` type) now returns **HTTP 400** (`MISSING_PARAMETER`) instead
23+
of silently binding `None`. Make a parameter optional with a default or an
24+
`X | None` type.
25+
- **CORS** is now auto-configured from `pyfly.web.cors.*` (`enabled`,
26+
`allowed-origins`, `allowed-methods`, `allowed-headers`, `allow-credentials`,
27+
`exposed-headers`, `max-age`) — secure-by-default disabled, like Spring's
28+
`CorsAutoConfiguration`. No more hand-passing a `CORSConfig` to `create_app`.
29+
- The `ExceptionConverterService` is now wired into the global error handler:
30+
non-PyFly exceptions (Pydantic, JSON, `TimeoutError`, plus user `@bean`
31+
converters) are translated to the right HTTP status before responding.
32+
33+
**Configuration & config-server**
34+
35+
- `get()` and `${...}` placeholder references now use **relaxed** kebab/snake
36+
matching (`${my-prop.sub-key}` resolves a value stored under `my_prop.sub_key`),
37+
consistent with `bind()`.
38+
- An **env-only** `PYFLY_<PREFIX>_*` variable with no file entry now binds to a
39+
`@config_properties` field.
40+
- **Remote config import**: when `pyfly.cloud.config.uri` (or
41+
`pyfly.config.import`) is set, the app fetches config from a config server at
42+
startup and merges it at high precedence (non-fatal unless
43+
`pyfly.cloud.config.fail-fast=true`).
44+
- The **config server now serves HTTP**: `GET/POST /{application}/{profile}[/{label}]`
45+
and `GET /_list` are mounted when `pyfly.config-server.enabled=true`; `fetch`
46+
returns the full Spring-Cloud-Config overlay set (app/profile → app/default →
47+
application/profile → application/default); the filesystem backend root is
48+
configurable and persistent via `pyfly.config-server.backend.root`.
49+
50+
**Admin dashboard**
51+
52+
- **[behavior]** `pyfly.admin.require-auth` is now **enforced** on every
53+
`/admin/api/*` route (401 unauthenticated, 403 missing every role in
54+
`pyfly.admin.allowed-roles`); the SPA shell and static assets stay public.
55+
- **Server mode** (`pyfly.admin.server.enabled`) wires an instance registry,
56+
mounts `/admin/api/instances`, and reports `serverMode=true`.
57+
- **Client self-registration**: with `pyfly.admin.client.url` +
58+
`auto-register=true`, the app registers with a remote admin server at startup
59+
and deregisters at shutdown.
60+
- Selecting the `TRACE` or `OFF` logger level now applies instead of silently
61+
failing; a beans SSE stream is served at `/admin/api/sse/beans`.
62+
63+
**Observability & actuator**
64+
65+
- Distributed tracing now **exports** spans (the `TracerProvider` previously had
66+
no span processor, so every `@span` was discarded). Configure via
67+
`pyfly.observability.tracing.exporter` (`otlp`|`console`|`none`) or the standard
68+
`OTEL_EXPORTER_OTLP_ENDPOINT`.
69+
- `/actuator/threaddump` reports the correct `className` (module) / `methodName`;
70+
`/actuator/prometheus` degrades gracefully (503) when `prometheus_client` is
71+
absent instead of raising.
72+
73+
**Starters**
74+
75+
- Fixed the property keys the `@enable_*_stack` bundles set so the bundled
76+
adapters actually activate: `pyfly.data.relational.enabled` /
77+
`pyfly.data.document.enabled`, `pyfly.eda.provider=auto`,
78+
`pyfly.security.enabled`, and `pyfly.web.actuator.enabled` (the previous keys
79+
were read by nothing).
80+
81+
**ECM**
82+
83+
- Storage and e-signature adapters are now selected from config
84+
(`pyfly.ecm.storage.provider` = `local`|`s3`/`aws`|`azure`;
85+
`pyfly.ecm.esignature.provider` = `noop`|`docusign`|`adobe`|`logalty`) — only
86+
local + noop were ever wired before. `DocumentService.delete` now reports
87+
storage-delete failures.
88+
89+
**Plugins**
90+
91+
- Extension points are now registered and type-validated
92+
(`register_extension_point`/`has_extension_point`; extensions are checked against
93+
their declared `@extension_point` class). Unloading a plugin
94+
(`PluginManager.remove`/`unload_all`) now unregisters its extensions.
95+
96+
**Orchestration / resilience / i18n / shell / testing / CLI**
97+
98+
- Orchestration REST: added `GET /api/orchestration/dlq/count`; **[behavior]**
99+
`GET /api/orchestration/executions` with no `status` now returns in-flight
100+
executions only.
101+
- `Bulkhead` now uses a single lock-guarded permit counter shared by sync and
102+
async calls, so a shared bulkhead can no longer desynchronise.
103+
- i18n message substitution now honors `java.text.MessageFormat` quoting
104+
(`''``'`, single-quoted text is literal).
105+
- `@shell_option(type=…, choices=…)` / `@shell_argument(type=…)` overrides are now
106+
honored by parameter inference.
107+
- `mock_bean(...)` descriptors are now injected into the test `ApplicationContext`,
108+
so DI-resolved collaborators receive the mock.
109+
- `pyfly new` now honors the package name entered in the interactive wizard.
110+
111+
### Docs
112+
113+
- New module guides for **i18n** and **WebSocket**; updated guides for web,
114+
configuration, config-server, admin, observability, actuator, ECM, plugins,
115+
resilience, transactional, validation, shell, testing, and CLI to match the
116+
changes above; doc indexes refreshed.
117+
118+
---
119+
9120
## v26.06.00 (2026-06-04)
10121

11122
### Spring Boot parity — observability, actuator & configuration

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<a href="https://github.com/fireflyframework"><img src="https://img.shields.io/badge/Firefly_Framework-official-ff6600?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyeiIvPjwvc3ZnPg==" alt="Firefly Framework"></a>
1212
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white" alt="Python 3.12+"></a>
1313
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
14-
<a href="#"><img src="https://img.shields.io/badge/version-26.05.04-brightgreen" alt="Version: 26.05.04"></a>
14+
<a href="#"><img src="https://img.shields.io/badge/version-26.06.01-brightgreen" alt="Version: 26.06.01"></a>
1515
<a href="#"><img src="https://img.shields.io/badge/type--checked-mypy%20strict-blue?logo=python&logoColor=white" alt="Type Checked: mypy strict"></a>
1616
<a href="#"><img src="https://img.shields.io/badge/code%20style-ruff-purple?logo=ruff&logoColor=white" alt="Code Style: Ruff"></a>
1717
<a href="#"><img src="https://img.shields.io/badge/async-first-brightgreen" alt="Async First"></a>

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ All module guides are organized in the [`modules/`](modules/README.md) directory
4343
| [Web Layer](modules/web.md) | REST controllers, routing, parameter binding, middleware, CORS, OpenAPI |
4444
| [Validation](modules/validation.md) | `Valid[T]` annotation, Pydantic model validation, structured 422 errors |
4545
| [WebFilters](modules/web-filters.md) | Request/response filter chain — `TransactionIdFilter`, `RequestLoggingFilter`, `SecurityHeadersFilter` |
46+
| [WebSocket](modules/websocket.md) | `@websocket_mapping`, `WebSocketSession`, `WebSocketHandler` lifecycle, `on_disconnect` hook, route discovery |
4647
| [Actuator](modules/actuator.md) | Health checks, beans endpoint, environment info, loggers, metrics |
4748
| [Custom Actuator Endpoints](modules/custom-actuator-endpoints.md) | Build your own actuator endpoints with the `ActuatorEndpoint` protocol |
4849

docs/cli.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ pyfly new [OPTIONS] # Interactive mode (prompts for all options)
122122
|----------|----------|-------------|
123123
| `name` | No | Project name. Omit to enter interactive mode. |
124124

125-
The project name is converted to a valid Python package name: `my-service` becomes `my_service` for the package directory.
125+
The project name is converted to a valid Python package name: `my-service` becomes `my_service` for the package directory. In interactive mode the wizard also prompts for a **Package name** (pre-filled with that derived value); if you change it, the custom package name is honored and used for the `src/<package>/` layout and all template imports.
126126

127127
### Options
128128

@@ -412,6 +412,10 @@ $ pyfly new
412412
**Features:**
413413
- Arrow-key navigation for archetype selection (single-select)
414414
- Space-bar toggling for feature selection (multi-select with checkbox)
415+
- A dedicated **Package name** prompt — defaults to the package name derived from
416+
the project name, but any custom value you enter is passed through to
417+
`generate_project(..., package_name=...)` and used as the actual package
418+
directory and import root
415419
- Confirmation summary with Rich-styled panel before creation
416420
- Graceful handling of Ctrl+C (exits cleanly without traceback)
417421

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ PyFly is organized into four layers:
272272
| Module | Description | Guide |
273273
|--------|-------------|-------|
274274
| **Web** | HTTP routing, controllers, middleware, OpenAPI | [Web Layer](modules/web.md) |
275+
| **WebSocket** | Real-time bidirectional endpoints with `@websocket_mapping` | [WebSocket](modules/websocket.md) |
276+
| **i18n** | Internationalisation, message bundles, locale resolution | [Internationalisation](modules/i18n.md) |
275277
| **Data** | Repository ports, derived queries, pagination, sorting, entity mapping | [Data Commons](modules/data.md) |
276278
| **Data Relational** | SQLAlchemy adapter — specifications, transactions, custom queries | [Data Relational](modules/data-relational.md) |
277279
| **Data Document** | MongoDB adapter — Beanie ODM, document repositories | [Data Document](modules/data-document.md) |
@@ -319,6 +321,8 @@ PyFly is organized into four layers:
319321
- [Configuration](modules/configuration.md) — YAML config, profiles, property binding, environment variables
320322
- [Error Handling](modules/error-handling.md) — Exception hierarchy, structured error responses
321323
- [Web Layer](modules/web.md) — Controllers, routing, parameter binding, middleware, CORS, OpenAPI
324+
- [WebSocket](modules/websocket.md) — `@websocket_mapping`, `WebSocketSession`, lifecycle hooks, route discovery
325+
- [Internationalisation (i18n)](modules/i18n.md) — Message bundles, locale fallback, `MessageFormat` placeholders, locale resolvers
322326
- [Actuator](modules/actuator.md) — Health checks, beans, environment, info endpoints
323327
- [Admin Dashboard](modules/admin.md) — Embedded management dashboard, real-time monitoring, server mode
324328
- [Data Commons](modules/data.md) — Repository ports, derived queries, pagination, sorting, entity mapping

docs/modules/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The building blocks that every PyFly application relies on.
3131
| [Configuration](configuration.md) | YAML/TOML config files, `Config` class, profile-specific overlays, `@config_properties` binding, environment variable overrides |
3232
| [Config Server](config-server.md) | Centralized config server (`ConfigServer`, `ConfigClient`), `ConfigBackend` SPI, in-memory & filesystem backends, Spring-Cloud-Config-compatible responses |
3333
| [Error Handling](error-handling.md) | 25+ exception types, `ErrorResponse`, `ErrorCategory`, `ErrorSeverity`, HTTP status mapping, structured error responses, `@exception_handler` |
34+
| [Starters](starters.md) | Layered bundles — `enable_core_stack`, `enable_web_stack`, `enable_application_stack`, `enable_data_stack`, `enable_domain_stack`, per-tier module activation |
3435

3536
---
3637

@@ -43,6 +44,8 @@ Build REST APIs with automatic OpenAPI documentation, validation, security heade
4344
| [Web Layer](web.md) | `@rest_controller`, `@get_mapping` / `@post_mapping` / `@put_mapping` / `@delete_mapping`, parameter binding (`Body`, `PathVar`, `QueryParam`, `Header`, `Cookie`), CORS configuration, OpenAPI 3.1 auto-generation, Swagger UI, ReDoc |
4445
| [Validation](validation.md) | `Valid[T]` annotation for explicit request validation, structured 422 error responses with field-level errors, `Valid[Body[T]]`, `Valid[QueryParam[T]]`, Pydantic `Field()` constraints, custom validators |
4546
| [WebFilters](web-filters.md) | `WebFilterChainMiddleware`, `OncePerRequestFilter` base class, URL pattern matching, built-in filters: `TransactionIdFilter`, `RequestLoggingFilter`, `SecurityHeadersFilter`, `SecurityFilter`, custom filter creation |
47+
| [WebSocket](websocket.md) | `@websocket_mapping`, `WebSocketSession` (accept/send/receive/close), `WebSocketHandler` lifecycle protocol, `on_disconnect` hook, Starlette `WebSocketRegistrar` route discovery, disconnect & exception handling |
48+
| [Server Module](server.md) | `ApplicationServerPort`, `EventLoopPort`, Granian / Uvicorn / Hypercorn adapters, uvloop / winloop / asyncio selection, cascading auto-configuration, `ServerProperties`, `pyfly run` |
4649
| [Actuator](actuator.md) | `/actuator/health`, `/actuator/beans`, `/actuator/env`, `/actuator/info`, `/actuator/loggers` (GET/POST), `/actuator/metrics`, HAL-style `_links` index, `ActuatorRegistry`, per-endpoint enable/disable |
4750
| [Custom Actuator Endpoints](custom-actuator-endpoints.md) | `ActuatorEndpoint` protocol, building custom endpoints, auto-discovery from DI container, configuration-driven enable/disable |
4851

@@ -119,6 +122,7 @@ Bridge to the rest of your stack — outbound webhooks, inbound webhooks, identi
119122
| [Callbacks (outbound webhooks)](callbacks.md) | Subscriptions, HMAC signing, retry, execution tracking |
120123
| [Webhooks (inbound)](webhooks.md) | Signature validation, idempotency, listener pattern |
121124
| [Notifications](notifications.md) | Email / SMS / push ports, dummy + SMTP adapters |
125+
| [Internationalisation (i18n)](i18n.md) | `MessageSource` port, `ResourceBundleMessageSource` (YAML/JSON bundles), locale fallback, `MessageFormat`-style placeholders, `AcceptHeaderLocaleResolver` / `FixedLocaleResolver`, `I18nAutoConfiguration` |
122126
| [IDP (Identity Provider)](idp.md) | `IdpAdapter` port + internal-DB reference adapter, login, MFA, roles |
123127
| [ECM (Content Management)](ecm.md) | Document storage, metadata, folders, e-signature ports + adapters |
124128
| [Plugins](plugins.md) | `@plugin`, `@extension`, `@extension_point`, `PluginManager`, dependency resolution |

docs/modules/actuator.md

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ automatically discovered from the DI container.
4444
15. [Info Endpoint](#info-endpoint)
4545
16. [Loggers Endpoint](#loggers-endpoint)
4646
- [GET /actuator/loggers](#get-actuatorloggers)
47-
- [POST /actuator/loggers](#post-actuatorloggers)
47+
- [POST /actuator/loggers/{name}](#post-actuatorloggersname)
4848
17. [Metrics Endpoint](#metrics-endpoint)
4949
18. [Custom Actuator Endpoints](#custom-actuator-endpoints)
5050
19. [make_starlette_actuator_routes()](#make_starlette_actuator_routes)
@@ -809,72 +809,92 @@ Lists all registered loggers with their configured and effective levels.
809809
"effectiveLevel": "INFO"
810810
}
811811
},
812-
"levels": ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "OFF"]
812+
"levels": ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"],
813+
"groups": {
814+
"web": {"configuredLevel": null, "members": ["pyfly.web", "uvicorn", "starlette"]},
815+
"sql": {"configuredLevel": null, "members": ["sqlalchemy", "pyfly.data"]}
816+
}
813817
}
814818
```
815819

820+
The endpoint uses Spring Boot's level vocabulary (`OFF`, `ERROR`, `WARN`,
821+
`INFO`, `DEBUG`, `TRACE`) rather than Python's, so the response is drop-in
822+
compatible with Spring Boot tooling. Python's `CRITICAL` collapses to `ERROR`;
823+
`TRACE` maps to numeric level 5 and `OFF` to a disabled logger.
824+
816825
| Field | Description |
817826
|---------------------|-------------------------------------------------------|
818827
| `loggers` | Dictionary of logger name to level information |
819828
| `configuredLevel` | Explicitly set level, or `null` if inheriting from parent |
820829
| `effectiveLevel` | Actual level in effect (may be inherited) |
821830
| `levels` | List of all recognized level names |
831+
| `groups` | Built-in logger groups (`web`, `sql`) and their members |
822832

823-
### POST /actuator/loggers
833+
### POST /actuator/loggers/{name}
824834

825-
Changes a logger's level at runtime. Send a JSON body with the logger name and the
826-
desired level:
835+
Changes a logger's level at runtime. The logger name is a **path parameter**;
836+
send a JSON body with the desired `configuredLevel`:
837+
838+
```
839+
POST /actuator/loggers/pyfly.web
840+
```
827841

828842
**Request body:**
829843

830844
```json
831845
{
832-
"logger": "pyfly.web",
833-
"level": "DEBUG"
846+
"configuredLevel": "DEBUG"
834847
}
835848
```
836849

837-
**Success response (200):**
850+
**Success response:** `204 No Content` (empty body).
851+
852+
To **reset** a logger to inherit from its parent (NOTSET), send `null` (or omit
853+
the field):
838854

839855
```json
840856
{
841-
"logger": "pyfly.web",
842-
"configuredLevel": "DEBUG"
857+
"configuredLevel": null
843858
}
844859
```
845860

846861
**Error response (400) -- unknown level:**
847862

848863
```json
849864
{
850-
"error": "Unknown level: INVALID"
865+
"error": "Unknown level: INVALID. Valid levels: OFF, ERROR, WARN, INFO, DEBUG, TRACE"
851866
}
852867
```
853868

854-
Use `"ROOT"` as the logger name to change the root logger level:
869+
Use `ROOT` as the logger name to change the root logger level:
855870

856-
```json
857-
{
858-
"logger": "ROOT",
859-
"level": "WARN"
860-
}
871+
```
872+
POST /actuator/loggers/ROOT
861873
```
862874

875+
A single logger's levels can also be inspected with
876+
`GET /actuator/loggers/{name}`, which returns `{configuredLevel, effectiveLevel}`.
877+
863878
**Example with curl:**
864879

865880
```bash
866881
# List all loggers
867882
curl http://localhost:8080/actuator/loggers
868883
869884
# Change pyfly.web logger to DEBUG
870-
curl -X POST http://localhost:8080/actuator/loggers \
885+
curl -X POST http://localhost:8080/actuator/loggers/pyfly.web \
871886
-H "Content-Type: application/json" \
872-
-d '{"logger": "pyfly.web", "level": "DEBUG"}'
887+
-d '{"configuredLevel": "DEBUG"}'
873888
874889
# Change root logger to WARN
875-
curl -X POST http://localhost:8080/actuator/loggers \
890+
curl -X POST http://localhost:8080/actuator/loggers/ROOT \
891+
-H "Content-Type: application/json" \
892+
-d '{"configuredLevel": "WARN"}'
893+
894+
# Reset pyfly.web to inherit from its parent
895+
curl -X POST http://localhost:8080/actuator/loggers/pyfly.web \
876896
-H "Content-Type: application/json" \
877-
-d '{"logger": "ROOT", "level": "WARN"}'
897+
-d '{"configuredLevel": null}'
878898
```
879899

880900
---
@@ -1298,9 +1318,9 @@ async def main():
12981318
# GET /actuator/beans -- all registered beans
12991319
# GET /actuator/env -- active profiles
13001320
# GET /actuator/info -- app name, version, description
1301-
# GET /actuator/loggers -- list all loggers and their levels
1302-
# POST /actuator/loggers -- change a logger's level at runtime
1303-
# GET /actuator/git -- custom endpoint: git/build info
1321+
# GET /actuator/loggers -- list all loggers and their levels
1322+
# POST /actuator/loggers/{name} -- change a logger's level at runtime
1323+
# GET /actuator/git -- custom endpoint: git/build info
13041324
13051325
import uvicorn
13061326
uvicorn.run(app, host="0.0.0.0", port=8080)
@@ -1390,14 +1410,15 @@ curl http://localhost:8080/actuator/loggers
13901410
# "pyfly.web": {"configuredLevel": null, "effectiveLevel": "INFO"},
13911411
# ...
13921412
# },
1393-
# "levels": ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "OFF"]
1413+
# "levels": ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"],
1414+
# "groups": {"web": {...}, "sql": {...}}
13941415
# }
13951416
1396-
# Change a logger's level at runtime
1397-
curl -X POST http://localhost:8080/actuator/loggers \
1417+
# Change a logger's level at runtime (logger name is a path parameter)
1418+
curl -X POST http://localhost:8080/actuator/loggers/pyfly.web \
13981419
-H "Content-Type: application/json" \
1399-
-d '{"logger": "pyfly.web", "level": "DEBUG"}'
1400-
# {"logger": "pyfly.web", "configuredLevel": "DEBUG"}
1420+
-d '{"configuredLevel": "DEBUG"}'
1421+
# (204 No Content)
14011422
14021423
# Custom git info endpoint
14031424
curl http://localhost:8080/actuator/git

0 commit comments

Comments
 (0)