diff --git a/pygitweb/actions.py b/pygitweb/actions.py
index 6cbbc20..c93f0e5 100644
--- a/pygitweb/actions.py
+++ b/pygitweb/actions.py
@@ -10,7 +10,7 @@ import mimetypes
 import os
 from datetime import UTC, datetime, timedelta, timezone
 from pathlib import Path
-from typing import Any, TypedDict
+from typing import Any, Literal, TypedDict
 from urllib.parse import parse_qsl, quote, urlencode
 
 import pygit2
@@ -101,6 +101,19 @@ def _validated_search_paths(project_root: Path, raw_paths: list[str]) -> list[st
 
 _SORT_KINDS: tuple[str, ...] = ("Path", "LastModified", "LastAccessed", "Created")
 
+SearchSortQuery = Literal[
+	"Path",
+	"-Path",
+	"LastModified",
+	"-LastModified",
+	"LastAccessed",
+	"-LastAccessed",
+	"Created",
+	"-Created",
+]
+
+SearchFlagQuery = Literal["true", "false"]
+
 
 def _parse_search_sort(value: str | None) -> PySortMode | None:
 	if value is None or not value.strip():
@@ -417,9 +430,9 @@ def git_search(
 	search_patterns: str | None,
 	search_paths: str | None,
 	search_globs: str | None,
-	search_heading: str | None,
-	search_multiline: str | None,
-	search_sort: str | None,
+	search_heading: SearchFlagQuery | None,
+	search_multiline: SearchFlagQuery | None,
+	search_sort: SearchSortQuery | None,
 	search_max_count: str | None,
 ) -> JSONResponse:
 	patterns = _split_query_list([search_patterns] if search_patterns is not None else [])
diff --git a/pygitweb/hooks_install_test.py b/pygitweb/hooks_install_test.py
index d078347..985d85b 100644
--- a/pygitweb/hooks_install_test.py
+++ b/pygitweb/hooks_install_test.py
@@ -246,13 +246,13 @@ class TestHookRoute:
 			)
 		assert resp.status_code == 409
 
-	def test_invalid_op_returns_400(self, project_env: dict[str, str]) -> None:
+	def test_invalid_op_returns_422(self, project_env: dict[str, str]) -> None:
 		with _client() as client:
 			resp = client.post(
 				f"/project/{project_env['project']}/hook",
 				params={"name": "post-receive.notify", "op": "noop"},
 			)
-		assert resp.status_code == 400
+		assert resp.status_code == 422
 
 	def test_unknown_name_returns_404(self, project_env: dict[str, str]) -> None:
 		with _client() as client:
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 01494b8..25e7cf0 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -14,7 +14,7 @@ from collections.abc import AsyncGenerator, Callable
 from contextlib import asynccontextmanager
 from datetime import UTC, datetime
 from pathlib import Path
-from typing import Annotated
+from typing import Annotated, Literal
 from urllib.parse import quote, urlencode
 
 import pygit2
@@ -24,6 +24,8 @@ from fastapi.staticfiles import StaticFiles
 
 from pygitweb import __meta__
 from pygitweb.actions import (
+	SearchFlagQuery,
+	SearchSortQuery,
 	git_blob,
 	git_blobdiff,
 	git_blobpatch,
@@ -106,6 +108,10 @@ from pygitweb.validation import is_valid_pathname
 
 UPDATES_SUPPORTED_ACTIONS: frozenset[str] = frozenset({"history", "log", "shortlog", "heads", "tags"})
 
+ProjectListAction = Literal["project_list"]
+ProjectListOrder = Literal["none", "project", "descr", "owner", "age"]
+HookInstallOp = Literal["add", "remove", "check"]
+
 
 def _parse_updates_flag(value: str | None) -> bool:
 	if value is None:
@@ -249,15 +255,11 @@ def _project_subpage_rows(project: str) -> list[list[str]]:
 @app.get("/", response_class=HTMLResponse)
 def git_project_list(
 	request: Request,
-	a: Annotated[str | None, Query(alias="a")] = None,
+	_action: Annotated[ProjectListAction | None, Query(alias="a")] = None,
 	pf: Annotated[str | None, Query(alias="pf")] = None,
-	o: Annotated[str | None, Query(alias="o")] = None,
+	_order: Annotated[ProjectListOrder | None, Query(alias="o")] = None,
 ):
 	"""Project list page. Port of git_project_list."""
-	if a and a != "project_list":
-		raise HTTPException(status_code=400, detail="Unknown action")
-	if o and o not in ("none", "project", "descr", "owner", "age"):
-		raise HTTPException(status_code=400, detail="Unknown order parameter")
 	project_filter = pf or ""
 	list_ = git_get_projects_list(
 		filter_path=project_filter,
@@ -598,7 +600,7 @@ def project_hook(
 	project: ValidatedPathProject,
 	name: Annotated[str, Query(alias="name")],
 	token: Annotated[str | None, Depends(access_token_from_request)],
-	op: Annotated[str, Query(alias="op")] = "check",
+	op: Annotated[HookInstallOp, Query(alias="op")] = "check",
 ) -> JSONResponse:
 	"""Add, remove, or check a pygittools hook sample (or bundle of samples) for a project.
 
@@ -608,8 +610,6 @@ def project_hook(
 	"""
 	if op != "check":
 		ensure_active_user_if_auth_enabled(token)
-	if op not in ("add", "remove", "check"):
-		raise HTTPException(status_code=400, detail="op must be one of: add, remove, check")
 	bundle = get_bundle(name)
 	sample = get_sample(name) if bundle is None else None
 	if bundle is None and sample is None:
@@ -736,9 +736,9 @@ async def dispatch(
 	search_patterns: Annotated[str | None, Query(alias="patterns")] = None,
 	search_paths: Annotated[str | None, Query(alias="paths")] = None,
 	search_globs: Annotated[str | None, Query(alias="globs")] = None,
-	search_heading: Annotated[str | None, Query(alias="heading")] = None,
-	search_multiline: Annotated[str | None, Query(alias="multiline")] = None,
-	search_sort: Annotated[str | None, Query(alias="sort")] = None,
+	search_heading: Annotated[SearchFlagQuery | None, Query(alias="heading")] = None,
+	search_multiline: Annotated[SearchFlagQuery | None, Query(alias="multiline")] = None,
+	search_sort: Annotated[SearchSortQuery | None, Query(alias="sort")] = None,
 	search_max_count: Annotated[str | None, Query(alias="max_count")] = None,
 ):
 	"""
