Skip to content

feat(idempotency): ActionAwareIdempotencyMiddleware — auto-wire from resolved Action (#182)#183

Merged
tonydspaniard merged 1 commit into
masterfrom
feat/182-action-aware-idempotency
May 31, 2026
Merged

feat(idempotency): ActionAwareIdempotencyMiddleware — auto-wire from resolved Action (#182)#183
tonydspaniard merged 1 commit into
masterfrom
feat/182-action-aware-idempotency

Conversation

@tonydspaniard
Copy link
Copy Markdown
Member

Closes #182. Closes the idempotency circle opened by #171.

Summary

Eliminates the manual per-route wiring step the idempotency epic left as host responsibility. A single global middleware reads each request's resolved Action via the altair:http:action request attribute, looks for the static idempotency() accessor the scaffolder emits (#174), and configures a per-request IdempotencyKeyMiddleware (#173) from that policy.

Pieces

  • ActionAwareIdempotencyMiddleware \xe2\x80\x94 PSR-15. Reads the attribute (configurable; default matches Altair\\Http\\Contracts\\MiddlewareInterface::ATTRIBUTE_ACTION). Passes through when no Action on request, when Action lacks idempotency(), or when the policy is malformed. Delegates to a fresh IdempotencyKeyMiddleware per request, parametrised by the spec's TTL + mode.
  • Altair\\Idempotency\\Hash\\TtlParser \xe2\x80\x94 pure utility. '24h' / '500ms' / '7d' → seconds. Rejects malformed strings with IdempotencyException. Sub-second TTLs round up to 1 second (storage TTLs are second-granular on the wire).

Host wiring

Two lines. Register IdempotencyConfiguration (binds IdempotencyStoreInterfaceInMemoryStore by default); add ActionAwareIdempotencyMiddleware to the middleware pipeline after DispatcherMiddleware and before ActionMiddleware:

$middleware->add(new ActionAwareIdempotencyMiddleware(
    store: $container->get(IdempotencyStoreInterface::class),
    responseFactory: $container->get(ResponseFactoryInterface::class),
    streamFactory: $container->get(StreamFactoryInterface::class),
));

That's the full host wiring. Endpoints whose specs carry idempotency: get the runtime policy; endpoints without the block pass through unchanged.

The manual IdempotencyKeyMiddleware recipe stays in the docs as an escape hatch for hosts that need to override the spec-driven policy (e.g. force mode: required globally).

Composer note

No coupling added to univeros/http. The attribute key is a constructor-injectable string defaulting to the value univeros/http ships, so the package stays portable.

Tests (+17)

  • TtlParserTest (11) \xe2\x80\x94 ms/s/m/h/d units, ms rounding behaviour, malformed rejection, zero handling.
  • ActionAwareIdempotencyMiddlewareTest (6) \xe2\x80\x94 pass-through paths (no attribute / no accessor / malformed policy), end-to-end replay via InMemoryStore, required-mode-from-action 400 path, custom attribute name configuration.

Test plan

  • composer cs \xe2\x80\x94 green
  • composer stan \xe2\x80\x94 green
  • composer rector (full tree, no cache) \xe2\x80\x94 green
  • composer test \xe2\x80\x94 6309 tests (+17 new), 0 new failures
  • bin/altair manifest:generate \xe2\x80\x94 clean
  • docs/packages/idempotency.md quick-start rewritten around the auto-wiring pattern

…resolved Action (#182)

Closes the idempotency circle from #171. Host applications no longer
hand-wire IdempotencyKeyMiddleware per route — a single global
middleware reads each request's resolved Action via the
altair:http:action attribute, looks for the static idempotency()
accessor the scaffolder emits (#174), and configures a per-request
IdempotencyKeyMiddleware (#173) from that policy.

Pieces
- Altair\Idempotency\Middleware\ActionAwareIdempotencyMiddleware
  - Reads attribute (configurable; default matches
    Altair\Http\Contracts\MiddlewareInterface::ATTRIBUTE_ACTION).
  - Passes through when no Action on request, when Action lacks
    idempotency(), or when the returned policy is malformed.
  - Delegates to a fresh IdempotencyKeyMiddleware per request,
    parametrised by spec ttl/mode.
- Altair\Idempotency\Hash\TtlParser
  - Pure utility: '24h' / '500ms' / '7d' → seconds. Rejects malformed
    strings with IdempotencyException. ms < 1000 round up to 1
    second (storage TTLs are second-granular on the wire).

Host wiring is now two lines: register IdempotencyConfiguration,
add ActionAwareIdempotencyMiddleware after DispatcherMiddleware and
before ActionMiddleware. docs/packages/idempotency.md quick-start
is rewritten around this pattern; the manual IdempotencyKeyMiddleware
recipe stays as the escape hatch for hosts that need to override the
spec-driven policy.

No coupling added to univeros/http. The attribute key is a
constructor-injectable string defaulting to the value
univeros/http ships, so the package stays portable.

Tests (+17)
- TtlParserTest: ms/s/m/h/d units, ms rounding, malformed rejection,
  zero handling.
- ActionAwareIdempotencyMiddlewareTest: pass-through paths (no
  attribute, no idempotency(), malformed policy), end-to-end replay
  via InMemoryStore, required-mode-from-action 400 path, custom
  attribute name configuration.

Closes #182. Closes the idempotency circle from #171.
@tonydspaniard tonydspaniard merged commit 5e14abb into master May 31, 2026
4 checks passed
@tonydspaniard tonydspaniard deleted the feat/182-action-aware-idempotency branch May 31, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ActionAwareIdempotencyMiddleware — auto-wire IdempotencyKeyMiddleware from the resolved Action

1 participant