Building a REST API for Crew Rest Requirements

Modern flight operations demand deterministic, auditable validation of crew rest before any schedule is published or modified. Manual reconciliation across spreadsheets and legacy dispatch systems introduces unacceptable latency, human error, and regulatory exposure. This guide walks through building a purpose-built REST endpoint that turns ad-hoc rest checks into a programmatic gate: it ingests a pair of consecutive duty boundaries, applies jurisdiction-specific thresholds, and returns an actionable compliance state a pairing engine can act on without human intervention. The endpoint is a thin, synchronous surface over the canonical duty states produced by the crew duty time taxonomy layer, and it belongs to the wider Core Architecture & Regulatory Mapping domain that translates legal text into executable logic.

The exact validation task this endpoint solves

The scoped problem is narrow and precise: given the UTC end instant of one duty and the UTC start instant of the next, confirm that the gap between them satisfies the applicable minimum rest, and return a structured verdict a scheduling engine can block or flag on. The difficulty is not the subtraction — it is that the “applicable minimum” is not a constant. Under the FAA Part 117 rule schema, the baseline is 10 consecutive hours (§117.25), but reduced-rest provisions can lower the floor, split-duty provisions (§117.15) change what counts as rest at all, and carrier buffer policies add margin on top. For crews under the EASA FTL compliance framework, the same request must resolve against ORO.FTL.235 minimums instead. The endpoint must therefore be a pure function of a normalized request plus a small, versioned set of regulatory constants — never local datetime arithmetic scattered through a scheduling UI.

Prerequisites

Step-by-step implementation

Step 1 — Model the request and response at the boundary

Validate untrusted input before it can reach the calculator. Naive (offset-free) timestamps are rejected outright, because a missing offset is the single most common source of cross-jurisdictional miscalculation. The verifiable output of this step is that a request carrying a naive datetime raises a ValidationError instead of silently proceeding.

from datetime import datetime
from enum import StrEnum
from zoneinfo import ZoneInfo

from pydantic import BaseModel, field_validator


class Jurisdiction(StrEnum):
    FAA = "faa"
    EASA = "easa"


class RestCheckRequest(BaseModel):
    crew_id: str
    prev_duty_end: datetime
    next_duty_start: datetime
    rest_facility_tz: str          # IANA identifier, e.g. "America/New_York"
    jurisdiction: Jurisdiction = Jurisdiction.FAA
    reduced_rest: bool = False

    @field_validator("prev_duty_end", "next_duty_start")
    @classmethod
    def require_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("timestamps must be timezone-aware")
        return v.astimezone(ZoneInfo("UTC"))

    @field_validator("rest_facility_tz")
    @classmethod
    def known_zone(cls, v: str) -> str:
        ZoneInfo(v)  # raises if the IANA key is unknown
        return v


class RestCheckResult(BaseModel):
    crew_id: str
    compliant: bool
    rest_hours: float
    required_hours: float
    violations: list[str]

Step 2 — Write the rest calculation as a pure function

Isolating the logic into a stateless function that accepts a validated request and returns a structured result makes it deterministic, replayable during audit, and trivially unit-testable. The regulatory floors live in named constants so a revision or a contractual buffer can be diffed and rolled back without touching control flow. The verifiable output is a RestCheckResult whose rest_hours equals the real elapsed interval and whose violations list is empty for a compliant pairing.

FAA_MIN_REST_H = 10.0
FAA_REDUCED_MIN_REST_H = 9.0
EASA_MIN_REST_H = 10.0


def required_rest_hours(req: RestCheckRequest) -> float:
    if req.jurisdiction is Jurisdiction.FAA:
        return FAA_REDUCED_MIN_REST_H if req.reduced_rest else FAA_MIN_REST_H
    return EASA_MIN_REST_H


def evaluate_rest(req: RestCheckRequest, buffer_h: float = 0.0) -> RestCheckResult:
    delta = req.next_duty_start - req.prev_duty_end
    rest_hours = delta.total_seconds() / 3600.0
    required = required_rest_hours(req) + buffer_h

    violations: list[str] = []
    if rest_hours < 0:
        violations.append("next duty starts before previous duty ends")
    if rest_hours < required:
        violations.append(f"rest {rest_hours:.2f}h below required {required:.2f}h")

    return RestCheckResult(
        crew_id=req.crew_id,
        compliant=not violations,
        rest_hours=round(rest_hours, 2),
        required_hours=required,
        violations=violations,
    )

Because both boundaries are normalized to UTC before the subtraction, rest_hours is the true elapsed duration in real hours — correct even when the rest window straddles a daylight-saving transition in rest_facility_tz. The facility zone is retained for report-time-keyed rules (the §117.13 FDP table row) that must be computed in local time, and is never mixed into this elapsed-time arithmetic.

Step 3 — Expose the endpoint and make the buffer tunable

A single POST route provides the synchronous validation gate. The compliance buffer — margin for ground handling, taxi, and crew transport variability — is read from an environment variable so flight ops managers can adjust strictness without redeploying the service. The verifiable output is a running service that returns 200 with a JSON verdict for valid input and 422 for malformed payloads.

import os

from fastapi import FastAPI, HTTPException

app = FastAPI(title="Crew Rest Compliance API")
REST_BUFFER_H = float(os.getenv("REST_BUFFER_HOURS", "0.0"))


@app.post("/v1/rest-checks", response_model=RestCheckResult)
def check_rest(req: RestCheckRequest) -> RestCheckResult:
    try:
        return evaluate_rest(req, buffer_h=REST_BUFFER_H)
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc

The scheduler submits duty boundaries, the service normalizes and validates them, the pure calculator returns a verdict, and the pairing engine blocks or flags the assignment automatically:

Synchronous crew-rest validation gate (sequence diagram) The Scheduler sends POST duty boundaries (UTC plus IANA timezone) to the Rest API. The Rest API performs a self-message to normalize and validate the payload, then sends an Evaluate rest interval message to the Compliance engine. The engine returns a dashed reply carrying the verdict, rest hours and violations. The Rest API returns a dashed 200 compliance result to the Scheduler. A note spanning Scheduler and Rest API states that the pairing is blocked or flagged automatically. Scheduler Rest API Compliance engine POST duty boundaries (UTC + IANA tz) Normalize & validate payload Evaluate rest interval API (return) --> Verdict + rest hours + violations Scheduler (return) --> 200 compliance result Pairing blocked or flagged automatically

Figure: Synchronous validation gate — the scheduler submits duty boundaries and receives a deterministic verdict the pairing engine can act on programmatically.

Verifying the endpoint

Assert the happy path and the boundary directly against the pure function, then extend to property-based tests that fuzz the interval. A ten-hour UTC gap under FAA rules must be compliant; a nine-hour-59-minute gap must not.

from datetime import datetime, timezone


def test_ten_hour_rest_is_compliant():
    req = RestCheckRequest(
        crew_id="C123",
        prev_duty_end=datetime(2026, 3, 1, 22, 0, tzinfo=timezone.utc),
        next_duty_start=datetime(2026, 3, 2, 8, 0, tzinfo=timezone.utc),
        rest_facility_tz="America/New_York",
    )
    result = evaluate_rest(req)
    assert result.compliant is True
    assert result.rest_hours == 10.0
    assert result.violations == []

Once verdicts are persisted for audit, reconcile them in the warehouse. This query surfaces any crew member with a logged rest breach in the last 24 hours, which should match the alerts fired to dispatch:

SELECT crew_id,
       COUNT(*) FILTER (WHERE NOT compliant) AS breach_count
FROM rest_check_log
WHERE checked_at >= now() - INTERVAL '24 hours'
GROUP BY crew_id
HAVING COUNT(*) FILTER (WHERE NOT compliant) > 0
ORDER BY breach_count DESC;

Failure modes and troubleshooting

FAQ

Does the endpoint measure rest in UTC or local time?

The elapsed rest duration is computed in UTC, because a fixed interval is the same number of real hours regardless of clock. Local time (rest_facility_tz) is retained only for rules whose thresholds are keyed on the local report time, such as the FDP table row, and those are evaluated separately from the rest subtraction.

How do I handle a split-duty rest under §117.15?

A split-duty rest happens inside a flight duty period, so it is not a gap between two duties and should not be sent to this endpoint as prev_duty_end/next_duty_start. Model it as a distinct in-duty rest segment in the taxonomy layer, credit it against the duty limit there, and call this endpoint only for the between-duty rest window.

What happens across a daylight-saving transition?

Nothing special is required for the duration check: converting both boundaries to UTC before subtracting yields the true elapsed hours even when the local window is nominally ten hours but really nine or eleven. Only local-time-keyed lookups need the transition-aware conversion, which is why rest_facility_tz is a required field.

Should reduced rest below nine hours ever pass?

No. Nine hours is the FAA floor for a reduced rest period; the calculator will not return compliant for anything shorter, and the buffer only ever raises the required minimum, never lowers it. Any request implying rest below the floor should be blocked and routed to a documented exception workflow.

How does this differ for EASA-regulated crews?

Set jurisdiction="easa" and the endpoint applies the ORO.FTL.235 minimum instead of the Part 117 figure. The request shape is identical; only the constant selected by required_rest_hours changes, which is exactly why the taxonomy routes the same normalized duty history to either schema without duplicating the state machine.

Back to Crew Duty Time Taxonomy Mapping.