Le trajet d'une requête
Pour comprendre l'API, le plus efficace est de suivre une requête de son arrivée à sa réponse. On prend Renee (id 5), qui pointe son arrivée depuis son téléphone :
POST /api/clocks/5
Authorization: Bearer eyJhbGciOi…
Content-Type: application/json
{ "action": "in", "client_id": "7f3c2a10-6c1e-4f1a-9a55-0d6c8f1b2e44" }Le trajet côté serveur
Cette page part du moment où la requête arrive dans l'API. Pour ce qui se passe avant (Traefik, le réseau, les conteneurs), voir Le trajet d'une requête côté DevOps.
Vue d'ensemble
Étape par étape
1. L'Endpoint : la porte d'entrée
Fichier : lib/timemanager_web/endpoint.ex. La requête traverse une suite de plugs, dans l'ordre :
| Plug | Rôle |
|---|---|
Plug.RequestId | donne un identifiant unique à la requête, pour la retrouver dans les journaux |
Plugs.CORS | dit au navigateur quels sites ont le droit d'appeler l'API |
Plug.Parsers | transforme le corps JSON en map Elixir : %{"action" => "in", …} |
TimemanagerWeb.Router | en dernier : l'aiguillage |
2. Le Router : l'aiguillage
Fichier : lib/timemanager_web/router.ex.
post "/clocks/:userID", ClockController, :createLe morceau :userID de l'adresse devient un paramètre : "userID" => "5". La route est dans un groupe qui passe par deux pipelines : :api (la requête doit accepter du JSON), puis :authenticated.
3. Authenticate : « qui es-tu ? »
Fichier : lib/timemanager_web/plugs/authenticate.ex. Ce plug :
- lit le jeton : l'en-tête
Authorization: Bearerpour le mobile, ou le cookie et l'en-têtex-csrf-tokenpour le web ; - vérifie sa signature et sa date d'expiration ;
- recharge le compte depuis la base : un compte supprimé est refusé tout de suite, même avec un jeton encore valide, et un changement de rôle s'applique immédiatement ;
- range le compte dans
conn.assigns.current_user.
Si une étape échoue, il répond 401 et arrête la requête (halt) : le contrôleur n'est jamais appelé.
4. Le contrôleur : « en as-tu le droit ? »
Fichier : lib/timemanager_web/controllers/clock_controller.ex, fonction create/2.
def create(conn, %{"userID" => user_id} = params) do
with {:ok, user_id} <- parse_id(user_id),
:ok <- Authorization.authorize(conn.assigns.current_user, :clock, user_id),
{:ok, clock, result} <- Clocks.clock(user_id, params) do
conn
|> put_status(status(result)) # 201 créé, 200 si déjà reçu
|> render(:show, clock: clock)
end
endLe contrôleur ne contient aucune règle : il enchaîne trois questions et laisse le with s'arrêter à la première qui échoue.
Le droit de pointer est décidé dans lib/timemanager/authorization.ex :
defp allowed?(%User{id: id}, :clock, id), do: trueid apparaît deux fois : la règle ne vaut que si la personne connectée est la personne qui pointe. Personne ne pointe pour un autre, pas même le General Manager. Sinon : 403.
5. Le métier : les règles
Fichier : lib/timemanager/clocks.ex, fonction clock/2.
DeviceActions.cast/2vérifie ce que le téléphone envoie :action,client_id, et éventuellementtime.Repo.transaction/1ouvre une transaction : tout ce qui suit réussit ou échoue ensemble.- La ligne de l'utilisateur est verrouillée (
FOR UPDATE) : deux pointages simultanés de Renee passent l'un après l'autre, jamais en même temps. replayed/2: ceclient_ida-t-il déjà été reçu ? Si oui, on renvoie le pointage existant (réponse perdue, file hors ligne renvoyée).insert_clock/4compare l'intention ("in") à l'état réel. Si Renee est déjà arrivée, c'est un 409already_clocked_in.record_shift/3ouvre la journée de travail avecWorkingtime.start_shift/3.
Si l'ouverture de la journée échoue, la transaction annule aussi le pointage : il n'existe jamais de pointage sans sa journée.
6. PostgreSQL : le dernier filet
Même si le code vérifie tout, la base a ses propres contraintes :
- un index unique garantit une seule journée ouverte par personne ;
- un autre garantit qu'un
client_idn'est enregistré qu'une fois par personne.
Si un bug laissait passer un doublon, la base dirait non. C'est la défense en profondeur.
7. La réponse
render(:show, clock: clock) passe par lib/timemanager_web/controllers/clock_json.ex, qui choisit exactement les champs envoyés :
{
"data": {
"id": 412, "user_id": 5, "status": true,
"time": "2026-10-13T06:30:00Z",
"client_id": "7f3c2a10-6c1e-4f1a-9a55-0d6c8f1b2e44",
"received_at": "2026-10-13T06:30:00Z"
}
}8. Et si quelque chose échoue ?
En haut du contrôleur, action_fallback TimemanagerWeb.FallbackController dit à Phoenix : « si l'action ne renvoie pas une réponse, passe son résultat à ce module ». Le {:error, …} sorti du with y devient une réponse propre :
Ce qui sort du with | Réponse |
|---|---|
{:error, :forbidden} | 403 {"error": "forbidden", "code": "forbidden"} |
{:error, {:conflict, :already_clocked_in}} | 409 {"code": "already_clocked_in"} |
{:error, :week_validated} | 409 {"code": "week_validated"} |
{:error, %Ecto.Changeset{}} | 422 avec errors (phrases) et details (codes) |
Toutes les erreurs de l'API passent par ce module : elles ont toutes la même forme, et chacune a un code stable que le site et l'application traduisent dans leur langue.
À retenir
| Étape | Question | Échec |
|---|---|---|
| Authenticate | qui es-tu ? | 401 |
| Authorization | en as-tu le droit ? | 403 |
| Contexte | est-ce permis par les règles ? | 409, 422 |
| Base | les données restent-elles cohérentes ? | 422 |
Essaie toi-même
Dans Swagger (/api/docs) : connecte-toi avec "client": "mobile", clique sur Authorize, puis envoie deux fois POST /api/clocks/{userID} avec {"action": "in"}. La première réponse est 201, la seconde 409 already_clocked_in. Retrouve dans insert_clock/4 la ligne qui a produit ce 409.