INW012 thin-endpoint¶
Co robi¶
Opt-in, domyślnie ostrzeżenie. Zgłasza endpoint HTTP, który sam wykonuje pracę, zamiast oddać ją przypadkowi użycia. Jedno zgłoszenie na endpoint, na linii jego def, wymienia z liczbami każdy sygnał, który przekroczył próg. Liczby obejmują funkcje pomocnicze, które endpoint wywołuje we własnym module, a zgłoszenie wymienia każdą z nich:
| Sygnał | Domyślnie | Co się liczy |
|---|---|---|
max-statements |
10 | Instrukcje w ciele funkcji, razem z zagnieżdżonymi. Nie liczy się docstring ani warunek ochronny (if <cond>: raise ... albo abort(404) we Flasku), ani handler except, który tylko rzuca wyjątek: mapowanie błędów na HTTP to zadanie endpointu, jakikolwiek to wyjątek. |
max-branches |
2 | if i elif, klauzule case i handlery except, bez warunków ochronnych. |
max-nesting |
2 | Ile instrukcji złożonych (if, for, while, try, with, match) zagnieżdża się jedna w drugiej. |
allow-loops |
false |
for i while w ciele, poza pętlą zawierającą yield: tak endpoint strumieniujący (zdarzenia wysyłane przez serwer, pobieranie strumieniowe) buduje odpowiedź, razem z pętlą while True: podtrzymującą połączenie. Wyrażenia listowe są dozwolone (allow-comprehensions = true), bo [ItemOut.from_orm(i) for i in items] kształtuje odpowiedź. |
deny-calls |
patrz niżej | Wywołania, których kwalifikowana nazwa pasuje do wzorca, przez importy i aliasy: requests.*, httpx.*, aiohttp.*, urllib.request.*, smtplib.*, boto3.*, redis.*, sqlalchemy.*, psycopg*.*, asyncpg.*, celery.*, django.db.connection.*. |
deny-receiver-types |
sesje SQLAlchemy i SQLModel | Wywołania metod na parametrze, którego adnotacja wskazuje jeden z tych typów: db.execute, session.add, db.commit. Adnotacja może być zwykła, Annotated[T, ...], Optional[T], T \| None albo aliasem zdefiniowanym w tym samym pliku. |
deny-receiver-params |
brak | To samo, po nazwie parametru, dla kodu bez typów (db, session). Domyślnie wyłączone, bo zgaduje. |
delegate-to |
nieustawione | Nazwy warstw albo prefiksy i selektory modułów. Gdy jest ustawione, endpoint musi wywołać co najmniej jedną funkcję albo metodę parametru, która stamtąd pochodzi. |
Endpoint to funkcja albo metoda, którą oznacza jedno z poniższych:
- FastAPI i Starlette: dekorator operacji ścieżki (
@router.get,post,put,patch,delete,head,options,tracealboapi_route) na nazwie, którą plik wiąże zFastAPI(...)alboAPIRouter(...)albo importuje; albo rejestracja trasy z dala od funkcji:router.add_api_route(path, handler),Route(path, handler)Starlette iAPIRoute(path, handler)FastAPI, z handlerem jako drugim argumentem alboendpoint=. - Flask:
@<x>.route,get,post,put,patchalbodeletena nazwie, którą plik wiąże zFlask(...)alboBlueprint(...)albo importuje;add_url_rule(rule, endpoint, view_func)z handlerem jako trzecim argumentem alboview_func=; metody nazwane jak metody HTTP w podklasieMethodVieworazdispatch_requestw podklasieView. - Litestar:
@get,@post,@put,@patch,@delete,@head,@routeiHTTPRouteHandlerimportowane zlitestar(albolitestar.handlers), tak samo na funkcjach, jak na metodachController. - Django i DRF:
@api_viewi@action; metody nazwane jak metody HTTP albo akcje ViewSet (list,create,retrieve,update,partial_update,destroy) w podklasie klasy widoku zdjango.views,rest_framework.views,rest_framework.genericsalborest_framework.viewsets(View,APIView,GenericAPIView,ListCreateAPIView,ModelViewSeti pozostałe); funkcja, którą wskazuje wywołaniepath(route, view)albore_path(...)w pliku; oraz, tylko w module, który wskazujemodules, funkcja z poziomu modułu, której pierwszy parametr torequest, bo "funkcja, która przyjmuje żądanie" jest gdzie indziej zbyt szeroka. - Własne: skonfigurowany dekorator (
decorators) oraz metody nazwane jak metody HTTP i akcje ViewSet w podklasie skonfigurowanej klasy bazowej (base-classes).
frameworks wybiera rozpoznawanie; domyślnie włączony jest każdy framework, o którym plik wspomina. Klasa bazowa widoku może pochodzić z tego samego pliku albo z innego modułu projektu (class OrdersView(BaseView), gdzie BaseView dziedziczy po APIView). Trasy websocket są pominięte: pętla odbioru to ich zadanie.
Pliki są sprawdzane bez względu na warstwę, poza modułami testowymi (przywraca je check-tests = true, zob. tabele opcji); modules zawęża regułę do części z nich.
Dlaczego to źle¶
Agenci wkładają całą funkcjonalność do handlera trasy: zapytanie, pętlę po wierszach, wywołanie API płatności, e-mail. Testy przechodzą, a warstwa serwisów, którą projekt przygotował, zostaje pusta. Reguły importów tego nie widzą: w typowych układach router i serwis leżą w tej samej warstwie, a handler dostaje sesję bazy danych przez Depends(get_db), w ogóle nie importując SQLAlchemy. Przewodniki stylu mówią to samo ("Don't do business logic in your API", HackSoft Django Styleguide), a ogólne lintery mierzą tylko rozmiar: PLR0915 Ruffa stosuje jeden limit 50 instrukcji do każdej funkcji i nie wie, która jest endpointem ani co wywołuje.
Przykład¶
[tool.inwards]
layers = [
{ name = "application", modules = ["store.application"] },
{ name = "api", modules = ["store.api"] },
]
[tool.inwards.rules]
extend-select = ["INW012"]
[tool.inwards.rules.thin-endpoint]
delegate-to = ["application"]
from dataclasses import dataclass
class OutOfStock(Exception): ...
@dataclass
class Order:
id: int
total: int
async def place_order(customer_id: int, lines: list[tuple[int, int]]) -> Order:
raise NotImplementedError
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from store.api.db import Customer, OrderRow, Product, get_db
router = APIRouter()
@router.post("/orders", status_code=201)
async def create_order(body: dict, db: Annotated[AsyncSession, Depends(get_db)]) -> dict:
customer = await db.scalar(select(Customer).where(Customer.id == body["customer"]))
if customer is None:
raise HTTPException(404, "customer not found")
total = 0
for product_id, qty in body["lines"]:
product = await db.get(Product, product_id)
if product.stock < qty:
raise HTTPException(409, "out of stock")
product.stock -= qty
total += product.price * qty
order = OrderRow(customer_id=customer.id, total=total)
db.add(order)
await db.commit()
httpx.post("https://hooks.example.com/orders", json={"order": order.id})
return {"id": order.id, "total": total}
store/api/orders.py:14:1: INW012 (warning) `create_order` is an HTTP endpoint with 11 statements (max 10), 1 loop over data, direct calls (`db.scalar`, `select`, `db.get`, `db.add`, `db.commit`, `httpx.post`) and no call into the `application` layer (`store.application`). Endpoints parse the request, call one use case, and shape the response.
fix: Move the work out of `create_order` into the `application` layer (`store.application`), and keep the endpoint to HTTP.
1. Move the logic (lines 15 to 29) into a function in the `application` layer (`store.application`) that takes plain arguments and returns a domain object or a result the endpoint maps to the response.
2. Move `db.scalar`, `db.get`, `db.add` and `db.commit` behind a repository in an outer layer, and give the use case a Protocol it can call.
3. Move `select` and `httpx.post` into an adapter (a repository, an API client or a background task) that the use case reaches through a Protocol.
4. In the endpoint keep only: read the request, call the use case, map its errors to `HTTPException`, and return the response model.
5. Don't move the code into a helper function in the same module: INW012 counts a helper's body as the endpoint's, and the work would still live in the HTTP layer.
docs: https://sircypkowskyy.github.io/inwards/rules/INW012/
Dwa warunki ochronne, które rzucają HTTPException, się nie liczą, więc ciało ma 11 instrukcji, a nie 13. Ostrzeżenie nie oblewa sprawdzenia: polecenie nadal kończy się kodem 0.
Przeniesienie ciała do funkcji pomocniczej w tym samym module go nie ukrywa. Instrukcje, pętle i wywołania funkcji pomocniczej liczą się jako instrukcje, pętle i wywołania endpointu, jej parametr db dostaje typ sesji argumentu, który do niej trafia, a zgłoszenie nazywa funkcję pomocniczą:
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from store.api.db import OrderRow, get_db
router = APIRouter()
@router.post("/orders", status_code=201)
async def create_order(body: dict, db: Annotated[AsyncSession, Depends(get_db)]) -> dict:
return await _create_order(body, db)
async def _create_order(body, db):
total = 0
for _product_id, qty in body["lines"]:
total += qty
order = OrderRow(customer_id=body["customer"], total=total)
db.add(order)
await db.commit()
httpx.post("https://hooks.example.com/orders", json={"order": order.id})
return {"id": order.id, "total": total}
store/api/orders.py:13:1: INW012 (warning) `create_order` is an HTTP endpoint with 1 loop over data, direct calls (`db.add`, `db.commit`, `httpx.post`) and no call into the `application` layer (`store.application`), counting the same-module helper `_create_order` (line 17). Endpoints parse the request, call one use case, and shape the response.
fix: Move the work out of `create_order` into the `application` layer (`store.application`), and keep the endpoint to HTTP.
1. Move the logic (line 14, and `_create_order` at lines 18 to 25) into a function in the `application` layer (`store.application`) that takes plain arguments and returns a domain object or a result the endpoint maps to the response.
Poprawione: endpoint wywołuje przypadek użycia i mapuje jego błąd na HTTP.
from fastapi import APIRouter, HTTPException
from store.application.orders import OutOfStock, place_order
router = APIRouter()
@router.post("/orders", status_code=201)
async def create_order(body: dict) -> dict:
try:
order = await place_order(body["customer"], body["lines"])
except OutOfStock as e:
raise HTTPException(409, str(e)) from e
return {"id": order.id, "total": order.total}
Przy jednym pakiecie na domenę, jak w presecie fastapi, moduły warstwy docelowej są selektorem, np. blog.*.service. Zgłoszenie wskazuje wtedy moduł, który selektor oznacza we własnym pakiecie endpointu, więc agent nie musi go wyprowadzać z wildcarda:
[tool.inwards]
layers = [
{ name = "service", modules = ["blog.*.service"] },
{ name = "router", modules = ["blog.*.router"] },
]
[tool.inwards.rules]
extend-select = ["INW012"]
[tool.inwards.rules.thin-endpoint]
delegate-to = ["service"]
from fastapi import APIRouter
router = APIRouter()
@router.get("/posts/{post_id}/words")
async def post_words(post_id: int, text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
return counts
blog/posts/router.py:7:1: INW012 (warning) `post_words` is an HTTP endpoint with 1 loop over data and no call into the `service` layer (`blog.posts.service`). Endpoints parse the request, call one use case, and shape the response.
fix: Move the work out of `post_words` into the `service` layer (`blog.posts.service`), and keep the endpoint to HTTP.
Wpis delegate-to, który sam jest selektorem, blog.*.service, daje "blog.posts.service (a module matching blog.*.service)". Gdy moduł endpointu nie pasuje do selektora (endpoint poza blog albo selektor kończący się wildcardem), zgłoszenie podaje selektor tak, jak go skonfigurowano.
Widoki oparte na klasach są traktowane tak samo. Akcja ViewSet DRF w tym samym projekcie jest nazwana razem ze swoją klasą:
import requests
from rest_framework import viewsets
from rest_framework.response import Response
class PostViewSet(viewsets.ViewSet):
def list(self, request):
posts = requests.get("https://cms.example.com/posts").json()
return Response([post for post in posts if post["published"]])
blog/posts/router.py:7:5: INW012 (warning) `PostViewSet.list` is an HTTP endpoint with a direct call to `requests.get` and no call into the `service` layer (`blog.posts.service`). Endpoints parse the request, call one use case, and shape the response.
Jak działa¶
Najpierw działa filtr tekstowy: plik, który nie wspomina żadnego z rozpoznawanych frameworków (fastapi, FastAPI, APIRouter, starlette albo add_api_route dla FastAPI, flask, litestar, django albo rest_framework) ani ostatniej części skonfigurowanego dekoratora albo klasy bazowej, nigdy nie jest parsowany. Gdy frameworks jest ustawione, liczą się tylko słowa wymienionych frameworków, a pozostałe rozpoznawanie nie działa. Plik, który przejdzie filtr, dostaje jedno własne parsowanie, a inny moduł jest czytany i parsowany tylko wtedy, gdy rejestracja w pliku wskazuje w nim handler.
- Endpointy pochodzą z dekoratorów, na dowolnej głębokości. Dekorator trasy FastAPI albo Flaska liczy się na nazwie, którą plik wiąże z aplikacją albo routerem, albo na nazwie importowanej, tak jak w modelu FastAPI, który czytają reguły FAPI. Router FastAPI zbudowany przez własną klasę albo fabrykę routera też się liczy, rozwiązany tak samo jak dla reguł FAPI (niżej); dekoratory Litestara i DRF liczą się po kwalifikowanej nazwie. Zgłoszenie dla metody nazywa ją
Klasa.metoda. Wpisdecoratorsdopasowuje kwalifikowaną nazwę tego, co dekorator wywołuje albo nazywa, z*i?jako wildcardami (*przechodzi też przez kropki, jak w fnmatch). - Klasy widoków liczą się po swoich klasach bazowych: klasie widoku frameworka, wpisie
base-classesalbo klasie, której własne klasy bazowe do nich prowadzą, w tym samym pliku albo, przez indeks projektu, w innym module projektu (cykl klas bazowych kończy szukanie). Liczą się tylko metody zdefiniowane we własnym ciele klasy; odziedziczone handlery i hooki, takie jakget_querysetalboperform_create, nie. - Zarejestrowane handlery pochodzą z
add_api_routeiadd_url_rulena aplikacji albo routerze (powiązanym w pliku albo importowanym) oraz z wywołańRoute(...),APIRoute(...),path(...)ire_path(...). Handler zdefiniowany na poziomie modułu w tym samym pliku jest zgłaszany na linii swojegodef, jak udekorowany. Handler z innego modułu projektu jest wyszukiwany przez indeks projektu, z podążaniem za reeksportami, takimi jakfrom .views import create_orderw__init__.py, i zgłaszany na rejestracji, bo to ten plik jest sprawdzany: komunikat podaje plik i linię handlera. Handler, który już jest endpointem we własnym module (oznacza go tam dekorator trasy), zostaje dla sprawdzenia tamtego modułu, więc nie jest zgłaszany dwa razy. - Metryki pochodzą z jednego przejścia po ciele funkcji. Zagnieżdżone
def,classalbolambdaliczy się jako jedna instrukcja, a to, co jest w środku, to jej sprawa. Drzewo znaczników liczy się jako jedna instrukcja i nie dodaje zagnieżdżenia (#360):with, którego wyrażenia kontekstu to wywołania niepowiązane z żadną nazwą (with tag.div(classes="card"):), zawierający tylko takie bloki, same wywołania jaktext(...),passi komentarze. Tak budują HTML dominate i tagflow, a drzewo to kształt odpowiedzi, nie funkcja aplikacji. - Health checki nie są zgłaszane (#360): endpoint, którego ścieżka trasy albo nazwa funkcji zawiera jako osobne słowo
health,healthz,healthcheck,livez,readyz,livenessalboreadiness(podział na/,-,_i.). Sprawdzanie bazy i cache to cel takiego endpointu; Kubernetes wywołuje/livezi/readyz(Kubernetes: API endpoints for health). - Funkcje pomocnicze to funkcje z poziomu modułu endpointu, które endpoint wywołuje po nazwie (
_create_order_impl(body)). Ich ciała liczą się jako ciało endpointu: instrukcje, gałęzie, pętle i wyrażenia listowe się sumują, zagnieżdżenie to najgłębsze z pojedynczych ciał, a ich wywołania są sortowane jak własne wywołania endpointu, łącznie zdelegate-to, więc funkcja pomocnicza, która wywołuje przypadek użycia, liczy się tak, jakby wywołał go endpoint. Funkcje pomocnicze funkcji pomocniczych też są śledzone, do trzech wywołań w głąb od endpointu, a każda funkcja liczy się raz na endpoint, co zatrzymuje też rekurencję. Parametr funkcji pomocniczej czyta własną adnotację albo, gdy jej nie ma, typ parametru endpointu, który do niego trafia. Jedno zgłoszenie na endpoint wymienia każdą wliczoną funkcję pomocniczą z jej linią; funkcje pomocnicze nie dostają własnych zgłoszeń, a funkcja pomocnicza wywoływana przez dwa endpointy liczy się w obu. Nie są śledzone: celDepends(...)(wywołuje go FastAPI, nie endpoint), inny endpoint, funkcja zagnieżdżona, funkcja importowana z innego modułu i metoda. - Wywołania są kwalifikowane przez importy pliku, więc
from httpx import post as sendsprawia, żesend(...)jest wywołaniemhttpx.post. Wywołanie na parametrze czyta adnotację parametru i cel jegoDepends(...):place_order(...)zplace_order: Annotated[PlaceOrder, Depends(get_place_order)]sięga i doPlaceOrder, i doget_place_order. delegate-topyta, która warstwa jest właścicielem każdej nazwy, do której sięga wywołanie (dla nazwy warstwy), albo czy pasuje do niej prefiks lub selektor.- Kroki poprawki nazywają cel z
delegate-to(dla selektora moduł, który oznacza on w pakiecie endpointu: część do ostatniego wildcarda jest dopasowywana do modułu endpointu, a dosłowna reszta zostaje doklejona), wywołania sesji do przeniesienia za repozytorium (w warstwie, z której pochodzi typ albo zależność sesji, gdy jest nią warstwa inna niż warstwa endpointu) i zakazane wywołania do przeniesienia do adaptera.
Jak naprawić¶
- Przenieś pętlę i reguły biznesowe do funkcji w warstwie z
delegate-to(przypadek użycia albo serwis), która przyjmuje zwykłe argumenty i zwraca obiekt domeny. - Przenieś wywołania sesji za repozytorium i daj przypadkowi użycia Protocol, który może wywołać.
- Przenieś wywołania na zewnątrz (HTTP, poczta, kolejki) do adaptera albo zadania w tle, które uruchamia przypadek użycia.
- W endpoincie zostaw tylko to, czego potrzebuje HTTP: odczytaj żądanie, wywołaj przypadek użycia, zmapuj jego błędy na HTTP (
HTTPException,abort(),Http404albo wyjątek DRF) i zwróć odpowiedź.
Nie dziel endpointu na funkcje pomocnicze w tym samym module, żeby zmieścić się w limitach: INW012 liczy je jako część endpointu, a praca nadal mieszkałaby w warstwie HTTP.
Bezpieczeństwo poprawki¶
Brak poprawki automatycznej (autofix: false). To, dokąd trafia logika i jak wygląda interfejs przypadku użycia, to decyzja projektowa.
Konfiguracja¶
Poziom: ostrzeżenie, więc zgłoszenie nie oblewa inwards check ani nie blokuje Stop gate; severity = { INW012 = "error" } czyni je blokującym. Opt-in: włącz regułę przez extend-select. Opcje są w [tool.inwards.rules.thin-endpoint] (tabele opcji), a agent nie może ich edytować (config guard obejmuje całą tabelę). Preset fastapi polecenia inwards init włącza INW012 jako ostrzeżenie z delegate-to = ["domain.service"], czyli modułem service każdego pakietu domeny:
[tool.inwards.rules]
extend-select = ["INW012"]
[tool.inwards.rules.thin-endpoint]
modules = ["shop.*.api"] # optional: only these modules
max-statements = 10 # false turns a threshold off
max-branches = 2
max-nesting = 2
allow-loops = false
allow-comprehensions = true
deny-calls = ["requests.*", "httpx.*"] # replaces the default list
extend-deny-calls = ["shop.integrations.stripe.*"] # adds to it
deny-receiver-types = ["sqlalchemy.orm.Session", "sqlalchemy.ext.asyncio.AsyncSession"]
deny-receiver-params = ["db"] # untyped code
delegate-to = ["application"] # layer names, prefixes or selectors
decorators = ["shop.http.endpoint"] # in-house endpoint decorators
frameworks = ["fastapi", "django"] # default: whatever the file mentions
base-classes = ["shop.http.Resource"] # in-house view base classes
max-statements,max-branches,max-nesting: liczba całkowita od 0 do 1000 albofalse, żeby wyłączyć sprawdzenie.deny-calls,extend-deny-calls,deny-receiver-types,decorators,base-classes: kwalifikowane nazwy z*i?jako wildcardami.deny-calls = []wyłącza sprawdzanie wywołań;deny-receiver-typeszastępuje swoją domyślną listę (sqlalchemy.orm.Session,sqlalchemy.orm.session.Session,sqlalchemy.ext.asyncio.AsyncSession,sqlalchemy.ext.asyncio.session.AsyncSession,sqlmodel.Session,sqlmodel.ext.asyncio.session.AsyncSession).delegate-to: nazwa warstwy zlayersalbo prefiks lub selektor modułu w gramatycelayers[].modules. Wpis, który nie jest żadnym z nich, to błąd konfiguracji.frameworks: różne wpisy spośródfastapi(razem ze Starlette),flask,litestaridjango(razem z DRF). Bez ustawienia rozpoznawanie frameworka działa w każdym pliku, który o nim wspomina.- ORM Django: wywołanie managera, takie jak
Order.objects.filter(...), domyślnie nie jest zakazane, bo tak kod Django czyta bazę danych, a domyślny zakaz oznaczałby każdy widok. Żeby traktować je w widoku jako pracę na bazie danych, dodajextend-deny-calls = ["*.objects.*"]. Suroweconnection.cursor()z Django jest na domyślnej liście.
Każde zgłoszenie można wyciszyć w linii przez # inwards: ignore[INW012] reason="..." na linii, na której jest zgłoszone: linii def albo, dla handlera z innego modułu, linii rejestracji.
Szablon może włączyć INW012 dla jednej ze swoich ról, razem z opcjami: router = { thin-endpoint = { max-statements = 8 } } ustawia modules na moduły roli.
Znane ograniczenia¶
- Logika biznesowa czy prezentacja. Endpoint, który buduje CSV, stronicuje albo ustawia nagłówki cache, jest długi z dobrych powodów. Zgłoszenie podaje liczby, nie werdykt; podnieś próg albo zawęź
modules. - Proste aplikacje CRUD. Niektóre zespoły celowo wybierają
db.get(...)w handlerze. Dlatego reguła jest opt-in. - Pośredniość.
s = db; s.execute(...), sesja przekazana przez**kwargs, alias typu importowany z innego modułu i adnotacje w napisach ukrywają wywołania. Funkcje pomocnicze są śledzone tylko we własnym module endpointu i tylko do trzech wywołań w głąb; funkcja pomocnicza importowana z innego modułu, metoda klasy i funkcja przechowywana w zmiennej albo słowniku nie są śledzone. - Handlery rejestrowane z innego modułu są zgłaszane tam, gdzie się je rejestruje, a nie tam, gdzie są zdefiniowane. Sprawdzenie po edycji modułu handlera (hook PostToolUse po edycji w nim) nie widzi rejestracji; widzi ją pełne sprawdzenie (
inwards check, Stop gate) i edycja pliku, który rejestruje. - Framework jest wykrywany po tekście. Plik jest czytany pod kątem frameworka tylko wtedy, gdy o nim wspomina, więc blueprint importowany z modułu projektu (
from shop.web import bp) w pliku, który nigdzie nie mówiflask, nie jest rozpoznawany, a klasa bazowa widoku z innego modułu jest śledzona tylko w pliku, który wspomina Django, DRF albo Flaska. Takie klasy bazowe wpisz dobase-classes. - Nie są łączone:
include(...)wurls.py,as_view()w rejestracji (zamiast tego liczą się własne metody klasy),Mountiadd_routeStarlette,register_blueprintFlaska,Router(route_handlers=[...])Litestara dla funkcji bez dekoratora oraz handler przekazany przez zmienną. - Odziedziczone handlery. Widok generyczny, który nadpisuje tylko hooki (
get_queryset,perform_create,get_serializer_class), nie jest sprawdzany: hooki nie są handlerami żądań, a handlery są we frameworku. - Warunki ochronne. Każde
raiseiabort(...)z Flaska liczy się jako mapowanie błędu na HTTP, także z wyjątkami samego projektu: mapuje je handler wyjątków całej aplikacji (exception_handlerFastAPI,EXCEPTION_HANDLERDRF), a z jednego pliku nie widać, które. Dlatego reguła biznesowa zapisana jako warunek ochronny (if order.total > limit: raise LimitExceeded()) też się nie liczy; praca wokół niej nadal tak.if(bezelifielse) alboexcept, którego blok wykonuje jedno samo wywołanie, a potemreturn, też liczy się jako warunek ochronny (#360): handler formularza HTMX odpowiada na błąd przezawait add_toast(request, "Not found.", "error")ireturn Nonetam, gdzie API JSON rzuca wyjątek. Krok biznesowy zapisany w ten sposób (if order.paid: await send_receipt(order); return order) też się nie liczy; dwa wywołania przedreturnjuż tak, podobnie jakreturnbez wywołania przed nim: widoki miniatur Saleora zwracająHttpResponseNotFound(...)z gałęzi, które zawierają ich logikę, a liczenie ich jako warunków ochronnych kosztowało trafne zgłoszenie. - Własne dekoratory, które opakowują handler i same wykonują pracę, nie są czytane.
- Opcje ładowania SQLAlchemy (
joinedload,selectinload,contains_eager,load_onlyi reszta opcji ładowania relacji i kolumn zsqlalchemy.orm) nigdy nie są zabronionym wywołaniem, nawet z ustawionymdeny-calls: mówią tylko zapytaniu, co załadować (SQLAlchemy: Relationship Loading Techniques), a endpoint przekazuje je repozytorium, żeby ukształtować odpowiedź. - Fałszywe alarmy: metoda
executena serwisie omyłkowo otypowanym jakoSession. Wyrażenie listowe z I/O liczy się jako dozwolone, choć wywołania w nim są nadal sprawdzane. Blokwithz samymi wywołaniami liczy się jako znaczniki, nawet gdy nimi nie jest (with transaction.atomic(): order.save()): jego wywołania są nadal sprawdzane, ale instrukcje i zagnieżdżenie się nie liczą. Na prawdziwym kodzie (niżej): webhook, który sprawdza podpis w endpoincie (sprawdzaj go w zależności,Depends(verify_signature), której INW012 nie liczy, bo wywołuje ją FastAPI), strumień zdarzeń wysyłanych przez serwer, którego generator jest funkcją pomocniczą z tego samego modułu, i handlery formularzy back office, które same aktualizują repozytorium. - Które routery się liczą (#358). Trasa liczy się na nazwie powiązanej z
FastAPI(...)alboAPIRouter(...)z FastAPI, a gdy plik wywołuje na tej nazwie dekorator trasy,include_routeralbo inną metodę routera, także z własną klasą albo fabryką routera, którą Inwards rozwiązuje przez importy projektu: podklasą klasy FastAPI;NAZWA = fabryka(...)na poziomie modułu, gdy własna fabryka klas mówi albo pokazuje, że zwraca klasę routera (-> type[APIRouter]albo podklasa, którą definiuje i zwraca, jakget_api_router_classw Polarze); dowolnymAPIRouter = <wywołanie>alboFastAPI = <wywołanie>na poziomie modułu; własną funkcją, która zwraca router, jeden krok w głąb; oraz nazwą zwracaną przez funkcję z adnotacją-> APIRouter(fabrykimake_*_router()w fastapi-clean-example). Samo wywołanie czegoś, co nazywa sięAPIRouter, się nie liczy: projekty OpenStack tak nazywają swoje routery WSGI. Argumenty fabryki nie są słowami kluczowymi routera, więc prefiks, tagi iresponses=routera z fabryki są nieznane, a jego trasy liczą się jako widoczne w schemacie. Plik, który nie zawiera żadnego słowa z filtra tekstowego (fastapi,APIRouter,include_router...), nie jest czytany.
Zmierzona precyzja¶
Zmierzona na korpusie prawdziwych repozytoriów 11 października 2026 (#182), z domyślnymi progami. Na potrzeby #182 endpointy Polara wymagały decorators = ["polar.*.router.get", "polar.*.router.post", ...], bo jego klasa routera powstaje w czasie działania; od #358 nie wymagają niczego.
| Repozytorium | Endpointy | Zgłoszenia przed → po #182 | Trafne zgłoszenia w próbie |
|---|---|---|---|
polar (z decorators) |
450 | 108 → 73 | 17 z 25 (68 %); przed poprawkami 8 z 22 (36 %) |
| full-stack-fastapi-template | 23 | 1 → 1 | 1 z 1 |
saleor (rejestracje re_path) |
n/d | 2 → 2 | 2 z 2 |
polar (bez decorators: Route Starlette w workerze) |
1 | 1 → 1 | 0 z 1 (health check) |
| polar, rozpoznane routery budowane w czasie działania (#358) | 526 | 1 → 135 | 10 z 27 (37 %), co piąte |
| fastapi-clean-example, trasy w fabrykach routerów (#358) | 15 | 0 → 0 | n/d |
| polar, rozpoznany każdy router (#358), znaczniki, wczesne odpowiedzi i health checki (#360) | 526 | 135 → 109 | 10 z 22 (45 %); wcześniej 10 z 27 (37 %) |
Dwie poprawki: warunek ochronny albo handler, który rzuca wyjątek samego projektu, się nie liczy (8 z 14 fałszywych alarmów w pierwszej próbie, kosztem 1 trafnego zgłoszenia: reguły biznesowej zapisanej jako dwa warunki ochronne), a opcje ładowania nie są zabronionymi wywołaniami (3 fałszywe alarmy w obu próbach). Progi (10 instrukcji, 2 gałęzie, 2 poziomy) się sprawdziły: z 73 zgłoszeń Polara tylko 3 przekraczają jeden sygnał o najwyżej 2 instrukcje, 1 gałąź albo 1 poziom, więc ich podniesienie prawie nie zmieniłoby liczby. Pozostałe fałszywe alarmy to prezentacja (46 z 73 zgłoszeń leży w renderowanym na serwerze back office Polara); dla takiego kodu zawęź modules. Gdy rozpoznawany jest każdy router Polara (#358), 107 ze 135 zgłoszeń leży w back office, a 17 z 27 w próbie to fałszywe alarmy: 15 stron renderowanych na serwerze i handlerów formularzy HTMX w back office, strumień zdarzeń wysyłanych przez serwer i webhook (#360).
#360 zmierzyło każdą zmianę na Polarze z rozpoznanym każdym routerem, na tej samej próbie co piątego z 27 zgłoszeń (10 trafnych): drzewa znaczników zmniejszyły liczbę zgłoszeń ze 135 do 121, same wczesne odpowiedzi do 129, same health checki do 133, a wszystkie trzy do 109. Z próby zniknęło 5 z 17 fałszywych alarmów i żadne trafne zgłoszenie. Z 26 zgłoszeń, które zniknęły, 2 to health checki, 23 to strony, okna dialogowe i handlery formularzy back office, a 1 to endpoint przekierowania, który wywołuje jeden serwis. full-stack-fastapi-template, fastapi-clean-example i python-ddd się nie zmieniły, a Saleor zachował swoje 2 zgłoszenia. 12 fałszywych alarmów, które zostały, to 9 handlerów formularzy i okien dialogowych back office, które wciąż wykonują 11 do 16 instrukcji wokół jednego wywołania serwisu albo repozytorium, strona back office, która renderuje wiersze w pętlach, strumień zdarzeń wysyłanych przez serwer i webhook.
Źródła¶
- #182: projekt reguły i to, co zostało na kolejne wersje.
- #271: funkcje pomocnicze z tego samego modułu i zarejestrowane trasy.
- HackSoft Django Styleguide: logikę biznesową trzymają serwisy i selektory, nie API.
- fastapi-best-practices: routery zajmują się HTTP, serwisy wykonują pracę.
- PLR0915, PLR0912 i C901 Ruffa mierzą każdą funkcję jednym limitem.