diff --git a/tiled/_tests/test_access_control.py b/tiled/_tests/test_access_control.py index 0601289e0..708ba8f4d 100644 --- a/tiled/_tests/test_access_control.py +++ b/tiled/_tests/test_access_control.py @@ -2,6 +2,7 @@ import numpy import pytest +from starlette.status import HTTP_403_FORBIDDEN from ..adapters.array import ArrayAdapter from ..adapters.mapping import MapAdapter @@ -212,7 +213,7 @@ def test_writing_blocked_by_access_policy(enter_password, context): with enter_password("secret1"): alice_client = from_context(context, username="alice") alice_client["d"]["x"].metadata - with fail_with_status_code(403): + with fail_with_status_code(HTTP_403_FORBIDDEN): alice_client["d"]["x"].update_metadata(metadata={"added_key": 3}) alice_client.logout() @@ -220,7 +221,7 @@ def test_writing_blocked_by_access_policy(enter_password, context): def test_create_blocked_by_access_policy(enter_password, context): with enter_password("secret1"): alice_client = from_context(context, username="alice") - with fail_with_status_code(403): + with fail_with_status_code(HTTP_403_FORBIDDEN): alice_client["e"].write_array([1, 2, 3]) alice_client.logout() diff --git a/tiled/_tests/test_allow_origins.py b/tiled/_tests/test_allow_origins.py index b1fb9e8b1..08b6d89b4 100644 --- a/tiled/_tests/test_allow_origins.py +++ b/tiled/_tests/test_allow_origins.py @@ -1,3 +1,5 @@ +from starlette.status import HTTP_200_OK, HTTP_400_BAD_REQUEST + from ..client import Context from ..server.app import build_app_from_config @@ -25,11 +27,11 @@ def test_cors_enforcement(): with Context.from_app(build_app_from_config(strict_config)) as context: request = context.http_client.build_request("OPTIONS", "/", headers=headers) response = context.http_client.send(request) - assert response.status_code == 400 + assert response.status_code == HTTP_400_BAD_REQUEST def test_allow_origins(): with Context.from_app(build_app_from_config(permissive_config)) as context: request = context.http_client.build_request("OPTIONS", "/", headers=headers) response = context.http_client.send(request) - assert response.status_code == 200 + assert response.status_code == HTTP_200_OK diff --git a/tiled/_tests/test_array.py b/tiled/_tests/test_array.py index 8ac6488a7..f00318deb 100644 --- a/tiled/_tests/test_array.py +++ b/tiled/_tests/test_array.py @@ -7,6 +7,7 @@ import httpx import numpy import pytest +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_406_NOT_ACCEPTABLE from ..adapters.array import ArrayAdapter from ..adapters.mapping import MapAdapter @@ -135,7 +136,7 @@ def test_block_validation(context): block_url = httpx.URL(client.item["links"]["block"]) # Malformed because it has only 2 dimensions, not 3. malformed_block_url = block_url.copy_with(params={"block": "0,0"}) - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): client.context.http_client.get(malformed_block_url).raise_for_status() @@ -149,7 +150,7 @@ def test_dask(context): def test_array_format_shape_from_cube(context): client = from_context(context)["cube"] - with fail_with_status_code(406): + with fail_with_status_code(HTTP_406_NOT_ACCEPTABLE): hyper_cube = client["tiny_hypercube"].export("test.png") # noqa: F841 diff --git a/tiled/_tests/test_asset_access.py b/tiled/_tests/test_asset_access.py index 7b707f5c3..88f85dbac 100644 --- a/tiled/_tests/test_asset_access.py +++ b/tiled/_tests/test_asset_access.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +from starlette.status import HTTP_403_FORBIDDEN from ..catalog import in_memory from ..client import Context, from_context @@ -77,5 +78,5 @@ def test_do_not_expose_raw_assets(tmpdir): with Context.from_app(app) as context: client = from_context(context, include_data_sources=True) client.write_array([1, 2, 3], key="x") - with fail_with_status_code(403): + with fail_with_status_code(HTTP_403_FORBIDDEN): client["x"].raw_export(tmpdir / "exported") diff --git a/tiled/_tests/test_authentication.py b/tiled/_tests/test_authentication.py index 4b0d1d2fe..934edcf89 100644 --- a/tiled/_tests/test_authentication.py +++ b/tiled/_tests/test_authentication.py @@ -7,6 +7,11 @@ import numpy import pytest +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_422_UNPROCESSABLE_ENTITY, +) from ..adapters.array import ArrayAdapter from ..adapters.mapping import MapAdapter @@ -80,12 +85,12 @@ def test_password_auth(enter_password, config): client.logout() # Bob's password should not work for Alice. - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): with enter_password("secret2"): from_context(context, username="alice") # Empty password should not work. - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): with enter_password(""): from_context(context, username="alice") @@ -302,9 +307,9 @@ def test_admin(enter_password, config): assert [role["name"] for role in user_roles] == ["user"] # As bob, admin functions should be disallowed. - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): context.admin.list_principals() - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): context.admin.show_principal(some_principal_uuid) @@ -358,7 +363,7 @@ def test_api_key_scopes(enter_password, config): metrics_key_info = context.create_api_key(scopes=["metrics"]) context.logout() context.api_key = metrics_key_info["secret"] - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): from_context(context) context.api_key = None @@ -366,7 +371,7 @@ def test_api_key_scopes(enter_password, config): with enter_password("secret2"): context.authenticate(username="bob") # Try to request a key with more scopes that the user has. - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): context.create_api_key(scopes=["admin:apikeys"]) # Request a key with reduced scope that can *only* read metadata. metadata_key_info = context.create_api_key(scopes=["read:metadata"]) @@ -374,7 +379,7 @@ def test_api_key_scopes(enter_password, config): context.api_key = metadata_key_info["secret"] restricted_client = from_context(context) restricted_client["A1"] - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): restricted_client["A1"].read() # no 'read:data' scope context.api_key = None @@ -405,7 +410,7 @@ def test_api_key_revoked(enter_password, config): assert len(context.whoami()["api_keys"]) == 0 context.logout() context.api_key = key_info["secret"] - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): from_context(context) @@ -420,7 +425,7 @@ def test_api_key_expiration(enter_password, config): context.logout() context.api_key = key_info["secret"] time.sleep(2) - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): from_context(context) @@ -435,7 +440,7 @@ def test_api_key_limit(enter_password, config): for i in range(authentication.API_KEY_LIMIT): context.create_api_key(note=f"key {i}") # Hit API key limit. - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): context.create_api_key(note="one key too many") finally: authentication.API_KEY_LIMIT = original_limit @@ -453,7 +458,7 @@ def test_session_limit(enter_password, config): context.authenticate(username="alice") context.logout() # Hit Session limit. - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): context.authenticate(username="alice") finally: authentication.SESSION_LIMIT = original_limit @@ -548,7 +553,7 @@ def test_admin_api_key_any_principal( context.http_client.get(resource).raise_for_status() context.api_key = None # The same endpoint fails without an API key - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): context.http_client.get(resource).raise_for_status() @@ -583,7 +588,7 @@ def test_admin_api_key_any_principal_exceeds_scopes(enter_password, principals_c context.authenticate(username="alice") principal_uuid = principals_context["uuid"]["bob"] - with fail_with_status_code(400) as fail_info: + with fail_with_status_code(HTTP_400_BAD_REQUEST) as fail_info: context.admin.create_api_key(principal_uuid, scopes=["read:principals"]) fail_message = " must be a subset of the principal's scopes " assert fail_message in fail_info.value.response.text @@ -601,7 +606,7 @@ def test_api_key_any_principal(enter_password, principals_context, username): context.authenticate(username="bob") principal_uuid = principals_context["uuid"][username] - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): context.admin.create_api_key(principal_uuid, scopes=["read:metadata"]) @@ -631,7 +636,7 @@ def test_api_key_bypass_scopes(enter_password, principals_context): {"api_key": api_key, "scopes": []}, ): context.api_key = query_params.pop("api_key", None) - with fail_with_status_code(401): + with fail_with_status_code(HTTP_401_UNAUTHORIZED): context.http_client.get( resource, params=query_params ).raise_for_status() diff --git a/tiled/_tests/test_client.py b/tiled/_tests/test_client.py index 11b3cdaf4..b7ae62c25 100644 --- a/tiled/_tests/test_client.py +++ b/tiled/_tests/test_client.py @@ -3,6 +3,7 @@ import httpx import pytest import yaml +from starlette.status import HTTP_400_BAD_REQUEST from ..adapters.mapping import MapAdapter from ..client import Context, from_context, from_profile, record_history @@ -27,12 +28,12 @@ def test_client_version_check(): # Too-old user agent should generate a 400. context.http_client.headers["user-agent"] = "python-tiled/0.1.0a77" - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): list(client) # Gibberish user agent should generate a 400. context.http_client.headers["user-agent"] = "python-tiled/gibberish" - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): list(client) diff --git a/tiled/_tests/test_compression.py b/tiled/_tests/test_compression.py index aa3d2b01a..b07bb2a9a 100644 --- a/tiled/_tests/test_compression.py +++ b/tiled/_tests/test_compression.py @@ -1,5 +1,6 @@ import numpy import pytest +from starlette.status import HTTP_200_OK from starlette.testclient import TestClient from ..adapters.array import ArrayAdapter @@ -30,8 +31,8 @@ def test_gzip_supported(app): data_response = client.get( "/api/v1/array/full/compresses_well", headers={"Accept": "text/csv"} ) - assert metadata_response.status_code == 200 - assert data_response.status_code == 200 + assert metadata_response.status_code == HTTP_200_OK + assert data_response.status_code == HTTP_200_OK assert "gzip" in metadata_response.headers["Content-Encoding"] assert "gzip" in data_response.headers["Content-Encoding"] @@ -44,7 +45,7 @@ def test_zstd_preferred(app): data_response = client.get( "/api/v1/array/full/compresses_well", headers={"Accept": "text/csv"} ) - assert metadata_response.status_code == 200 - assert data_response.status_code == 200 + assert metadata_response.status_code == HTTP_200_OK + assert data_response.status_code == HTTP_200_OK assert "zstd" in metadata_response.headers["Content-Encoding"] assert "zstd" in data_response.headers["Content-Encoding"] diff --git a/tiled/_tests/test_dataframe.py b/tiled/_tests/test_dataframe.py index 6209340a3..9a0d3250e 100644 --- a/tiled/_tests/test_dataframe.py +++ b/tiled/_tests/test_dataframe.py @@ -1,6 +1,7 @@ import numpy import pandas.testing import pytest +from starlette.status import HTTP_400_BAD_REQUEST from ..adapters.dataframe import DataFrameAdapter from ..adapters.mapping import MapAdapter @@ -200,7 +201,7 @@ def test_redundant_query_parameters(context): context.http_client.get(url_path, params=params).raise_for_status() # It is an error to include query parameter 'column' AND 'field' - with fail_with_status_code(400) as response: + with fail_with_status_code(HTTP_400_BAD_REQUEST) as response: params = original_params context.http_client.get(url_path, params=params).raise_for_status() assert "'field'" in response.text diff --git a/tiled/_tests/test_directory_walker.py b/tiled/_tests/test_directory_walker.py index cb034956a..e116ac7d1 100644 --- a/tiled/_tests/test_directory_walker.py +++ b/tiled/_tests/test_directory_walker.py @@ -8,6 +8,7 @@ import pytest import tifffile import yaml +from starlette.status import HTTP_415_UNSUPPORTED_MEDIA_TYPE from ..adapters.hdf5 import HDF5Adapter from ..adapters.tiff import TiffAdapter @@ -323,7 +324,7 @@ def test_unknown_mimetype(tmpdir): is_directory=False, parameter="test", ) - with fail_with_status_code(415): + with fail_with_status_code(HTTP_415_UNSUPPORTED_MEDIA_TYPE): client.new( key="x", structure_family="array", diff --git a/tiled/_tests/test_metrics.py b/tiled/_tests/test_metrics.py index 4db4dccf4..1024e22aa 100644 --- a/tiled/_tests/test_metrics.py +++ b/tiled/_tests/test_metrics.py @@ -1,6 +1,7 @@ import re from fastapi import APIRouter +from starlette.status import HTTP_404_NOT_FOUND, HTTP_500_INTERNAL_SERVER_ERROR from ..client import Context, from_context from ..server.app import build_app_from_config @@ -43,8 +44,8 @@ def test_error_code(): assert total_request_time(client, 500) - baseline_time[500] == 0 client.context.http_client.raise_server_exceptions = False response_500 = client.context.http_client.get("/error") - assert response_500.status_code == 500 + assert response_500.status_code == HTTP_500_INTERNAL_SERVER_ERROR assert total_request_time(client, 500) - baseline_time[500] > 0 response_404 = client.context.http_client.get("/does_not_exist") - assert response_404.status_code == 404 + assert response_404.status_code == HTTP_404_NOT_FOUND assert total_request_time(client, 404) - baseline_time[404] > 0 diff --git a/tiled/_tests/test_openapi.py b/tiled/_tests/test_openapi.py index 9aab0d7e9..c9601e1dc 100644 --- a/tiled/_tests/test_openapi.py +++ b/tiled/_tests/test_openapi.py @@ -1,4 +1,5 @@ import pytest +from starlette.status import HTTP_200_OK from ..adapters.mapping import MapAdapter from ..client import Context @@ -41,7 +42,7 @@ def test_openapi_username_password_login(context): invalid path, because that has happened before. """ response = context.http_client.get("/openapi.json") - assert response.status_code == 200 + assert response.status_code == HTTP_200_OK openapi = response.json() token_url = openapi["components"]["securitySchemes"]["OAuth2PasswordBearer"][ "flows" diff --git a/tiled/_tests/test_queries.py b/tiled/_tests/test_queries.py index 7af19eebb..2974fb6a7 100644 --- a/tiled/_tests/test_queries.py +++ b/tiled/_tests/test_queries.py @@ -7,6 +7,7 @@ import numpy import pytest import pytest_asyncio +from starlette.status import HTTP_400_BAD_REQUEST from ..adapters.array import ArrayAdapter from ..adapters.mapping import MapAdapter @@ -165,7 +166,7 @@ def test_full_text(client): if client.metadata["backend"] in {"sqlite"}: def cm(): - return fail_with_status_code(400) + return fail_with_status_code(HTTP_400_BAD_REQUEST) else: cm = nullcontext @@ -180,7 +181,7 @@ def test_regex(client): if client.metadata["backend"] in {"postgresql", "sqlite"}: def cm(): - return fail_with_status_code(400) + return fail_with_status_code(HTTP_400_BAD_REQUEST) else: cm = nullcontext diff --git a/tiled/_tests/test_routes.py b/tiled/_tests/test_routes.py index f003ce11c..bec5f7961 100644 --- a/tiled/_tests/test_routes.py +++ b/tiled/_tests/test_routes.py @@ -1,5 +1,6 @@ import pytest from httpx import AsyncClient +from starlette.status import HTTP_200_OK from ..server.app import build_app @@ -10,4 +11,4 @@ async def test_meta_routes(path): app = build_app({}) async with AsyncClient(app=app, base_url="http://test") as client: response = await client.get(path) - assert response.status_code == 200 + assert response.status_code == HTTP_200_OK diff --git a/tiled/_tests/test_size_limit.py b/tiled/_tests/test_size_limit.py index f2592dc51..ea0a2bf33 100644 --- a/tiled/_tests/test_size_limit.py +++ b/tiled/_tests/test_size_limit.py @@ -4,6 +4,7 @@ import numpy import pandas import pytest +from starlette.status import HTTP_400_BAD_REQUEST from ..adapters.array import ArrayAdapter from ..adapters.dataframe import DataFrameAdapter @@ -63,9 +64,9 @@ def test_array(client, tmpdir): path = str(tmpdir / "test.csv") client["tiny_array"].read() # This is fine. client["tiny_array"].export(path) # This is fine. - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): client["small_array"].read() # too big - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): client["small_array"].export(path) # too big @@ -78,7 +79,7 @@ def test_dataframe(client, tmpdir): path = str(tmpdir / "test.csv") client["tiny_df"].read() # This is fine. client["tiny_df"].export(path) - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): client["small_df"].read() # too big - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): client["small_df"].export(path) # too big diff --git a/tiled/_tests/test_validation.py b/tiled/_tests/test_validation.py index 0acd7572f..7d269e5e9 100644 --- a/tiled/_tests/test_validation.py +++ b/tiled/_tests/test_validation.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd import pytest +from starlette.status import HTTP_400_BAD_REQUEST from ..client import Context, from_context from ..config import merge @@ -71,17 +72,17 @@ def test_validators(client): df = pd.DataFrame({"a": np.zeros(10), "b": np.zeros(10)}) client.write_dataframe(df, metadata={"foo": 1}, specs=["foo"]) - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): # not expected structure family a = np.ones((5, 7)) client.write_array(a, metadata={}, specs=["foo"]) - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): # column names are not expected df = pd.DataFrame({"x": np.zeros(10), "y": np.zeros(10)}) client.write_dataframe(df, metadata={}, specs=["foo"]) - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): # missing expected metadata df = pd.DataFrame({"a": np.zeros(10), "b": np.zeros(10)}) client.write_dataframe(df, metadata={}, specs=["foo"]) @@ -121,7 +122,7 @@ def test_unknown_spec_strict(tmpdir): client = from_context(context) a = np.ones((5, 7)) client.write_array(a, metadata={}, specs=["a"]) - with fail_with_status_code(400): + with fail_with_status_code(HTTP_400_BAD_REQUEST): # unknown spec 'b' should be rejected client.write_array(a, metadata={}, specs=["b"]) diff --git a/tiled/_tests/test_writing.py b/tiled/_tests/test_writing.py index 8aaeda912..5defb90e3 100644 --- a/tiled/_tests/test_writing.py +++ b/tiled/_tests/test_writing.py @@ -14,6 +14,11 @@ import pytest import sparse from pandas.testing import assert_frame_equal +from starlette.status import ( + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, + HTTP_422_UNPROCESSABLE_ENTITY, +) from ..catalog import in_memory from ..catalog.adapter import CatalogContainerAdapter @@ -268,25 +273,25 @@ def test_limits(tree): x = client.write_array([1, 2, 3], specs=max_allowed_specs) x.update_metadata(specs=max_allowed_specs) # no-op too_many_specs = max_allowed_specs + ["one_too_many"] - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): client.write_array([1, 2, 3], specs=too_many_specs) - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): x.update_metadata(specs=too_many_specs) # Specs cannot repeat. has_repeated_spec = ["spec0", "spec1", "spec0"] - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): client.write_array([1, 2, 3], specs=has_repeated_spec) - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): x.update_metadata(specs=has_repeated_spec) # A given spec cannot be too long. max_allowed_chars = ["a" * MAX_SPEC_CHARS] client.write_array([1, 2, 3], specs=max_allowed_chars) too_many_chars = ["a" * (1 + MAX_SPEC_CHARS)] - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): client.write_array([1, 2, 3], specs=too_many_chars) - with fail_with_status_code(422): + with fail_with_status_code(HTTP_422_UNPROCESSABLE_ENTITY): x.update_metadata(specs=too_many_chars) @@ -305,7 +310,7 @@ def test_metadata_revisions(tree): assert len(ac.metadata_revisions[:]) == 2 ac.metadata_revisions.delete_revision(1) assert len(ac.metadata_revisions[:]) == 1 - with fail_with_status_code(404): + with fail_with_status_code(HTTP_404_NOT_FOUND): ac.metadata_revisions.delete_revision(1) @@ -341,7 +346,7 @@ async def test_delete(tree): assert len(assets_before_delete) == 1 # Writing again with the same name fails. - with fail_with_status_code(409): + with fail_with_status_code(HTTP_409_CONFLICT): client.write_array( [1, 2, 3], metadata={"date": datetime.now(), "array": numpy.array([1, 2, 3])}, @@ -378,13 +383,13 @@ async def test_delete_non_empty_node(tree): # Cannot delete non-empty nodes assert "a" in client - with fail_with_status_code(409): + with fail_with_status_code(HTTP_409_CONFLICT): client.delete("a") assert "b" in a - with fail_with_status_code(409): + with fail_with_status_code(HTTP_409_CONFLICT): a.delete("b") assert "c" in b - with fail_with_status_code(409): + with fail_with_status_code(HTTP_409_CONFLICT): b.delete("c") assert "d" in c assert not list(d) # leaf is empty diff --git a/tiled/catalog/adapter.py b/tiled/catalog/adapter.py index 909fabc21..b0b4e8c02 100644 --- a/tiled/catalog/adapter.py +++ b/tiled/catalog/adapter.py @@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.orm import selectinload from sqlalchemy.sql.expression import cast +from starlette.status import HTTP_404_NOT_FOUND, HTTP_415_UNSUPPORTED_MEDIA_TYPE from tiled.queries import ( Comparison, @@ -616,7 +617,7 @@ async def create_node( else: if data_source.mimetype not in self.context.adapters_by_mimetype: raise HTTPException( - status_code=415, + status_code=HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=( f"The given data source mimetype, {data_source.mimetype}, " "is not one that the Tiled server knows how to read." @@ -732,7 +733,7 @@ async def delete(self): if result.rowcount == 0: # TODO Abstract this from FastAPI? raise HTTPException( - status_code=404, + status_code=HTTP_404_NOT_FOUND, detail=f"No node {self.node.id}", ) assert ( @@ -809,7 +810,7 @@ async def delete_revision(self, number): if result.rowcount == 0: # TODO Abstract this from FastAPI? raise HTTPException( - status_code=404, + status_code=HTTP_404_NOT_FOUND, detail=f"No revision {number} for node {self.node.id}", ) assert ( diff --git a/tiled/client/auth.py b/tiled/client/auth.py index bcffbf877..bc39bc3d4 100644 --- a/tiled/client/auth.py +++ b/tiled/client/auth.py @@ -2,6 +2,7 @@ from pathlib import Path import httpx +from starlette.status import HTTP_401_UNAUTHORIZED from .utils import SerializableLock, handle_error @@ -91,7 +92,7 @@ def sync_auth_flow(self, request, attempt=0): if access_token is not None: request.headers["Authorization"] = f"Bearer {access_token}" response = yield request - if (access_token is None) or (response.status_code == 401): + if (access_token is None) or (response.status_code == HTTP_401_UNAUTHORIZED): # Maybe the token cached in memory is stale. maybe_new_access_token = self.sync_get_token( "access_token", reload_from_disk=True @@ -113,7 +114,7 @@ def sync_auth_flow(self, request, attempt=0): self.refresh_url, refresh_token, self.csrf_token ) token_response = yield token_request - if token_response.status_code == 401: + if token_response.status_code == HTTP_401_UNAUTHORIZED: # Refreshing the token failed. # Discard the expired (or otherwise invalid) refresh_token. self.sync_clear_token("refresh_token") diff --git a/tiled/client/cache_control.py b/tiled/client/cache_control.py index b54c149a3..ab1ffb816 100644 --- a/tiled/client/cache_control.py +++ b/tiled/client/cache_control.py @@ -9,6 +9,13 @@ import attr import httpx +from starlette.status import ( + HTTP_200_OK, + HTTP_203_NON_AUTHORITATIVE_INFORMATION, + HTTP_300_MULTIPLE_CHOICES, + HTTP_301_MOVED_PERMANENTLY, + HTTP_308_PERMANENT_REDIRECT, +) logger = logging.getLogger(__name__) @@ -113,7 +120,13 @@ def __init__( self, *, cacheable_methods: tp.Tuple[str, ...] = ("GET",), - cacheable_status_codes: tp.Tuple[int, ...] = (200, 203, 300, 301, 308), + cacheable_status_codes: tp.Tuple[int, ...] = ( + HTTP_200_OK, + HTTP_203_NON_AUTHORITATIVE_INFORMATION, + HTTP_300_MULTIPLE_CHOICES, + HTTP_301_MOVED_PERMANENTLY, + HTTP_308_PERMANENT_REDIRECT, + ), always_cache: bool = False, ) -> None: self.cacheable_methods = cacheable_methods diff --git a/tiled/client/constructors.py b/tiled/client/constructors.py index 8a035c89d..71eb0ee24 100644 --- a/tiled/client/constructors.py +++ b/tiled/client/constructors.py @@ -2,6 +2,7 @@ import collections.abc import httpx +from starlette.status import HTTP_401_UNAUTHORIZED from ..utils import import_object, prepend_to_sys_path from .container import DEFAULT_STRUCTURE_CLIENT_DISPATCH, Container @@ -145,7 +146,7 @@ def from_context( ).json() except ClientError as err: if ( - (err.response.status_code == 401) + (err.response.status_code == HTTP_401_UNAUTHORIZED) and (context.api_key is None) and (context.http_client.auth is None) ): diff --git a/tiled/client/container.py b/tiled/client/container.py index bfb8e821c..4ee5da122 100644 --- a/tiled/client/container.py +++ b/tiled/client/container.py @@ -7,6 +7,7 @@ from dataclasses import asdict import entrypoints +from starlette.status import HTTP_404_NOT_FOUND from ..adapters.utils import IndexersMixin from ..iterviews import ItemsView, KeysView, ValuesView @@ -318,7 +319,7 @@ def __getitem__(self, keys, _ignore_inlined_contents=False): ) ).json() except ClientError as err: - if err.response.status_code == 404: + if err.response.status_code == HTTP_404_NOT_FOUND: # If this is a scalar lookup, raise KeyError("X") not KeyError(("X",)). err_arg = keys[i:] if len(err_arg) == 1: diff --git a/tiled/client/context.py b/tiled/client/context.py index 66288bd61..68c9b6422 100644 --- a/tiled/client/context.py +++ b/tiled/client/context.py @@ -10,6 +10,7 @@ import appdirs import httpx +from starlette.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED from .._version import __version__ as tiled_version from ..utils import UNSET, DictView @@ -553,7 +554,7 @@ def authenticate( }, auth=None, ) - if (access_response.status_code == 400) and ( + if (access_response.status_code == HTTP_400_BAD_REQUEST) and ( access_response.json()["detail"]["error"] == "authorization_pending" ): print(".", end="", flush=True) @@ -640,7 +641,7 @@ def force_auth_refresh(self): csrf_token, ) token_response = self.http_client.send(refresh_request, auth=None) - if token_response.status_code == 401: + if token_response.status_code == HTTP_401_UNAUTHORIZED: raise CannotRefreshAuthentication( "Session cannot be refreshed. Log in again." ) diff --git a/tiled/client/dataframe.py b/tiled/client/dataframe.py index 0287a8773..cc689ee1b 100644 --- a/tiled/client/dataframe.py +++ b/tiled/client/dataframe.py @@ -1,5 +1,6 @@ import dask import dask.dataframe.core +from starlette.status import HTTP_404_NOT_FOUND from ..serialization.table import deserialize_arrow, serialize_arrow from ..utils import APACHE_ARROW_FILE_MIME_TYPE, UNCHANGED @@ -191,7 +192,7 @@ def __getitem__(self, column): ) ).json() except ClientError as err: - if err.response.status_code == 404: + if err.response.status_code == HTTP_404_NOT_FOUND: raise KeyError(column) raise item = content["data"] diff --git a/tiled/client/transport.py b/tiled/client/transport.py index bb30a13e4..48fc3ea3f 100644 --- a/tiled/client/transport.py +++ b/tiled/client/transport.py @@ -5,6 +5,14 @@ import typing as tp import httpx +from starlette.status import ( + HTTP_200_OK, + HTTP_203_NON_AUTHORITATIVE_INFORMATION, + HTTP_300_MULTIPLE_CHOICES, + HTTP_301_MOVED_PERMANENTLY, + HTTP_304_NOT_MODIFIED, + HTTP_308_PERMANENT_REDIRECT, +) from .cache import Cache from .cache_control import ByteStreamWrapper, CacheControl @@ -31,7 +39,13 @@ def __init__( transport: tp.Optional[httpx.BaseTransport] = None, cache: tp.Optional[Cache] = None, cacheable_methods: tp.Tuple[str, ...] = ("GET",), - cacheable_status_codes: tp.Tuple[int, ...] = (200, 203, 300, 301, 308), + cacheable_status_codes: tp.Tuple[int, ...] = ( + HTTP_200_OK, + HTTP_203_NON_AUTHORITATIVE_INFORMATION, + HTTP_300_MULTIPLE_CHOICES, + HTTP_301_MOVED_PERMANENTLY, + HTTP_308_PERMANENT_REDIRECT, + ), always_cache: bool = False, ): self.controller = CacheControl( @@ -91,7 +105,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: # But, below _collect_ the response with the content in it. if self.cache is not None: - if response.status_code == 304: + if response.status_code == HTTP_304_NOT_MODIFIED: if __debug__: logger.debug( "Server validated as fresh cached entry for: %s", request diff --git a/tiled/client/utils.py b/tiled/client/utils.py index cf7a200a8..a29a3395c 100644 --- a/tiled/client/utils.py +++ b/tiled/client/utils.py @@ -7,6 +7,7 @@ import httpx import msgpack +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR from ..utils import path_from_uri @@ -21,7 +22,7 @@ def handle_error(response): except httpx.RequestError: raise # Nothing to add in this case; just raise it. except httpx.HTTPStatusError as exc: - if response.status_code < 500: + if response.status_code < HTTP_500_INTERNAL_SERVER_ERROR: # Include more detail that httpx does by default. if response.headers["Content-Type"] == "application/json": detail = response.json().get("detail", "") diff --git a/tiled/server/app.py b/tiled/server/app.py index 1da155e22..cf6be65d3 100644 --- a/tiled/server/app.py +++ b/tiled/server/app.py @@ -22,6 +22,15 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.responses import FileResponse +from starlette.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, + HTTP_500_INTERNAL_SERVER_ERROR, +) from ..authenticators import Mode from ..config import construct_build_app_kwargs @@ -220,7 +229,7 @@ async def lookup_file(path, try_app=True): try: stat_result = await anyio.to_thread.run_sync(os.stat, full_path) except PermissionError: - raise HTTPException(status_code=401) + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) except FileNotFoundError: # This may be a URL that has meaning to the client-side application, # such as /ui//metadata/a/b/c. @@ -228,14 +237,14 @@ async def lookup_file(path, try_app=True): if try_app: response = await lookup_file("index.html", try_app=False) return response - raise HTTPException(status_code=404) + raise HTTPException(status_code=HTTP_404_NOT_FOUND) except OSError: raise return FileResponse( full_path, stat_result=stat_result, method="GET", - status_code=200, + status_code=HTTP_200_OK, ) app.mount( @@ -286,7 +295,7 @@ async def tiled_ui_settings(): @app.exception_handler(Conflicts) async def conflicts_exception_handler(request: Request, exc: Conflicts): message = exc.args[0] - return JSONResponse(status_code=409, content={"detail": message}) + return JSONResponse(status_code=HTTP_409_CONFLICT, content={"detail": message}) @app.exception_handler(UnsupportedQueryType) async def unsupported_query_type_exception_handler( @@ -294,7 +303,7 @@ async def unsupported_query_type_exception_handler( ): query_type = exc.args[0] return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "detail": f"The query type {query_type!r} is not supported on this node." }, @@ -321,7 +330,7 @@ async def unhandled_exception_handler( return await http_exception_handler( request, HTTPException( - 500, + HTTP_500_INTERNAL_SERVER_ERROR, "Internal server error", headers={"X-Tiled-Request-ID": correlation_id.get() or ""}, ), @@ -706,7 +715,8 @@ async def double_submit_cookie_csrf_protection(request: Request, call_next): ): if not csrf_cookie: return Response( - status_code=403, content="Expected tiled_csrf_token cookie" + status_code=HTTP_403_FORBIDDEN, + content="Expected tiled_csrf_token cookie", ) # Get the token from the Header or (if not there) the query parameter. csrf_token = request.headers.get(CSRF_HEADER_NAME) @@ -715,13 +725,14 @@ async def double_submit_cookie_csrf_protection(request: Request, call_next): csrf_token = parsed_query.get(CSRF_QUERY_PARAMETER) if not csrf_token: return Response( - status_code=403, + status_code=HTTP_403_FORBIDDEN, content=f"Expected {CSRF_QUERY_PARAMETER} query parameter or {CSRF_HEADER_NAME} header", ) # Securely compare the token with the cookie. if not secrets.compare_digest(csrf_token, csrf_cookie): return Response( - status_code=403, content="Double-submit CSRF tokens do not match" + status_code=HTTP_403_FORBIDDEN, + content="Double-submit CSRF tokens do not match", ) response = await call_next(request) @@ -744,7 +755,7 @@ async def client_compatibility_check(request: Request, call_next): parsed_version = packaging.version.parse(raw_version) except Exception: return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "detail": ( f"Python Tiled client is version is reported as {raw_version}. " @@ -755,7 +766,7 @@ async def client_compatibility_check(request: Request, call_next): else: if parsed_version < MINIMUM_SUPPORTED_PYTHON_CLIENT_VERSION: return JSONResponse( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, content={ "detail": ( f"Python Tiled client reports version {parsed_version}. " @@ -878,7 +889,7 @@ async def capture_metrics_prometheus(request: Request, call_next): except Exception: # Make an ephemeral response solely for 'capture_request_metrics'. # It will only be used in the 'finally' clean-up block. - only_for_metrics = Response(status_code=500) + only_for_metrics = Response(status_code=HTTP_500_INTERNAL_SERVER_ERROR) response = only_for_metrics # Now re-raise the exception so that the server can generate and # send an appropriate response to the client. diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index f17a46901..6877711ce 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -30,6 +30,13 @@ from sqlalchemy.future import select from sqlalchemy.orm import selectinload from sqlalchemy.sql import func +from starlette.status import ( + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, +) # To hide third-party warning # .../jose/backends/cryptography_backend.py:18: CryptographyDeprecationWarning: @@ -120,7 +127,7 @@ async def __call__(self, request: Request) -> Optional[str]: return None if scheme.lower() != "apikey": raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( "Authorization header must include the authorization type " "followed by a space and then the secret, as in " @@ -161,7 +168,7 @@ def create_refresh_token(session_id, secret_key, expires_delta): def decode_token(token, secret_keys): credentials_exception = HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) @@ -220,7 +227,7 @@ async def get_decoded_access_token( payload = decode_token(access_token, settings.secret_keys) except ExpiredSignatureError: raise HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail="Access token has expired. Refresh token.", headers=headers_for_401(request, security_scopes), ) @@ -266,7 +273,7 @@ async def get_current_principal( except Exception: # Not valid hex, therefore not a valid API key raise HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key", headers=headers_for_401(request, security_scopes), ) @@ -290,7 +297,7 @@ async def get_current_principal( await db.commit() else: raise HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key", headers=headers_for_401(request, security_scopes), ) @@ -309,7 +316,7 @@ async def get_current_principal( } else: raise HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key", headers=headers_for_401(request, security_scopes), ) @@ -357,7 +364,7 @@ async def get_current_principal( # https://examples.com/subpath/ and obtain a list of # authentication providers and endpoints. raise HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail=( "Not enough permissions. " f"Requires scopes {security_scopes.scopes}. " @@ -511,7 +518,9 @@ async def route( request.state.endpoint = "auth" user_session_state = await authenticator.authenticate(request) if not user_session_state: - raise HTTPException(status_code=401, detail="Authentication failure") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Authentication failure" + ) session = await create_session( settings, db, @@ -622,7 +631,7 @@ async def route( "action": action, "message": message, }, - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, ) user_session_state = await authenticator.authenticate(request) if not user_session_state: @@ -635,7 +644,7 @@ async def route( "Ask administrator to see logs for details." ), }, - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, ) session = await create_session( settings, @@ -673,7 +682,9 @@ async def route( device_code = bytes.fromhex(device_code_hex) except Exception: # Not valid hex, therefore not a valid device_code - raise HTTPException(status_code=401, detail="Invalid device code") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Invalid device code" + ) pending_session = await lookup_valid_pending_session_by_device_code( db, device_code ) @@ -683,7 +694,9 @@ async def route( detail="No such device_code. The pending request may have expired.", ) if pending_session.session_id is None: - raise HTTPException(400, {"error": "authorization_pending"}) + raise HTTPException( + HTTP_400_BAD_REQUEST, {"error": "authorization_pending"} + ) session = pending_session.session # The pending session can only be used once. await db.delete(pending_session) @@ -711,7 +724,7 @@ async def route( ) if not user_session_state or not user_session_state.user_name: raise HTTPException( - status_code=401, + status_code=HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) @@ -884,7 +897,9 @@ async def principal( ) ).scalar() if principal_orm is None: - raise HTTPException(status_code=404, detail=f"No such Principal {uuid}") + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail=f"No such Principal {uuid}" + ) latest_activity = await latest_principal_activity(db, principal_orm) return json_or_msgpack( request, @@ -944,11 +959,11 @@ async def revoke_session( # Find this session in the database. session = await lookup_valid_session(db, session_id) if session is None: - raise HTTPException(409, detail=f"No session {session_id}") + raise HTTPException(HTTP_409_CONFLICT, detail=f"No session {session_id}") session.revoked = True db.add(session) await db.commit() - return Response(status_code=204) + return Response(status_code=HTTP_204_NO_CONTENT) @base_authentication_router.delete("/session/revoke/{session_id}") @@ -967,13 +982,13 @@ async def revoke_session_by_id( if principal.uuid != session.principal.uuid: # TODO Add a scope for doing this for other users. raise HTTPException( - 404, + HTTP_404_NOT_FOUND, detail="Sessions does not exist or requester has insufficient permissions", ) session.revoked = True db.add(session) await db.commit() - return Response(status_code=204) + return Response(status_code=HTTP_204_NO_CONTENT) async def slide_session(refresh_token, settings, db): @@ -981,7 +996,8 @@ async def slide_session(refresh_token, settings, db): payload = decode_token(refresh_token, settings.secret_keys) except ExpiredSignatureError: raise HTTPException( - status_code=401, detail="Session has expired. Please re-authenticate." + status_code=HTTP_401_UNAUTHORIZED, + detail="Session has expired. Please re-authenticate.", ) # Find this session in the database. session = await lookup_valid_session(db, payload["sid"]) @@ -992,7 +1008,8 @@ async def slide_session(refresh_token, settings, db): # Do not leak (to a potential attacker) whether this has been *revoked* # specifically. Give the same error as if it had expired. raise HTTPException( - status_code=401, detail="Session has expired. Please re-authenticate." + status_code=HTTP_401_UNAUTHORIZED, + detail="Session has expired. Please re-authenticate.", ) # Update Session info. session.time_last_refreshed = now @@ -1075,16 +1092,17 @@ async def current_apikey_info( request.state.endpoint = "auth" if api_key is None: raise HTTPException( - status_code=401, detail="No API key was provided with this request." + status_code=HTTP_401_UNAUTHORIZED, + detail="No API key was provided with this request.", ) try: secret = bytes.fromhex(api_key) except Exception: # Not valid hex, therefore not a valid API key - raise HTTPException(status_code=401, detail="Invalid API key") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key") api_key_orm = await lookup_valid_api_key(db, secret) if api_key_orm is None: - raise HTTPException(status_code=401, detail="Invalid API key") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid API key") return json_or_msgpack(request, schemas.APIKey.from_orm(api_key_orm).dict()) @@ -1113,7 +1131,7 @@ async def revoke_apikey( ) await db.delete(api_key_orm) await db.commit() - return Response(status_code=204) + return Response(status_code=HTTP_204_NO_CONTENT) @base_authentication_router.get( @@ -1144,7 +1162,9 @@ async def whoami( ) ).scalar() if principal_orm is None: - raise HTTPException(status_code=401, detail="Principal no longer exists.") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Principal no longer exists." + ) latest_activity = await latest_principal_activity(db, principal_orm) return json_or_msgpack( request, diff --git a/tiled/server/core.py b/tiled/server/core.py index 10e0d93d6..a4b8818a5 100644 --- a/tiled/server/core.py +++ b/tiled/server/core.py @@ -18,6 +18,7 @@ import msgpack from fastapi import HTTPException, Response from starlette.responses import JSONResponse, Send, StreamingResponse +from starlette.status import HTTP_200_OK, HTTP_304_NOT_MODIFIED, HTTP_400_BAD_REQUEST # Some are not directly used, but they register things on import. from .. import queries @@ -137,7 +138,9 @@ async def apply_search(tree, filters, query_registry): continue tree = tree.search(query) except QueryValueError as err: - raise HTTPException(status_code=400, detail=err.args[0]) + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=err.args[0] + ) if key_lookups: # Duplicates are technically legal because *any* query can be given # with multiple parameters. @@ -176,7 +179,8 @@ def apply_sort(tree, sort): if sorting: if not hasattr(tree, "sort"): raise HTTPException( - status_code=400, detail="This Tree does not support sorting." + status_code=HTTP_400_BAD_REQUEST, + detail="This Tree does not support sorting.", ) tree = tree.sort(sorting) @@ -360,7 +364,7 @@ async def construct_data_response( headers["Expires"] = expires.strftime(HTTP_EXPIRES_HEADER_FORMAT) if request.headers.get("If-None-Match", "") == etag: # If the client already has this content, confirm that. - return Response(status_code=304, headers=headers) + return Response(status_code=HTTP_304_NOT_MODIFIED, headers=headers) if filename: headers["Content-Disposition"] = f'attachment; filename="{filename}"' serializer = serialization_registry.dispatch(spec, media_type) @@ -669,7 +673,9 @@ def resolve_media_type(request): return media_type -def json_or_msgpack(request, content, expires=None, headers=None, status_code=200): +def json_or_msgpack( + request, content, expires=None, headers=None, status_code=HTTP_200_OK +): media_type = resolve_media_type(request) with record_timing(request.state.metrics, "tok"): etag = md5(str(content).encode()).hexdigest() @@ -679,7 +685,7 @@ def json_or_msgpack(request, content, expires=None, headers=None, status_code=20 headers["Expires"] = expires.strftime(HTTP_EXPIRES_HEADER_FORMAT) if request.headers.get("If-None-Match", "") == etag: # If the client already has this content, confirm that. - return Response(status_code=304, headers=headers) + return Response(status_code=HTTP_304_NOT_MODIFIED, headers=headers) if media_type == "application/x-msgpack": return MsgpackResponse( content, diff --git a/tiled/server/dependencies.py b/tiled/server/dependencies.py index 404f4a5d7..59773f73d 100644 --- a/tiled/server/dependencies.py +++ b/tiled/server/dependencies.py @@ -3,6 +3,7 @@ import pydantic from fastapi import Depends, HTTPException, Query, Request, Security +from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND from ..media_type_registration import ( deserialization_registry as default_deserialization_registry, @@ -107,7 +108,7 @@ async def inner( # You can see this, but you cannot perform the requested # operation on it. raise HTTPException( - status_code=403, + status_code=HTTP_403_FORBIDDEN, detail=( "Not enough permissions to perform this action on this node. " f"Requires scopes {scopes}. " @@ -115,14 +116,16 @@ async def inner( ), ) except NoEntry: - raise HTTPException(status_code=404, detail=f"No such entry: {path_parts}") + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail=f"No such entry: {path_parts}" + ) # Fast path for the common successful case if (structure_families is None) or ( entry.structure_family in structure_families ): return entry raise HTTPException( - status_code=404, + status_code=HTTP_404_NOT_FOUND, detail=( f"The node at {path} has structure family {entry.structure_family} " "and this endpoint is compatible with structure families " diff --git a/tiled/server/router.py b/tiled/server/router.py index a09a85540..297830805 100644 --- a/tiled/server/router.py +++ b/tiled/server/router.py @@ -12,6 +12,14 @@ from jmespath.exceptions import JMESPathError from pydantic import BaseSettings from starlette.responses import FileResponse +from starlette.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_405_METHOD_NOT_ALLOWED, + HTTP_406_NOT_ACCEPTABLE, +) from .. import __version__ from ..structures.core import StructureFamily @@ -199,12 +207,12 @@ async def search( headers=headers, ) except NoEntry: - raise HTTPException(status_code=404, detail="No such entry.") + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="No such entry.") except WrongTypeForRoute as err: - raise HTTPException(status_code=404, detail=err.args[0]) + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail=err.args[0]) except JMESPathError as err: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"Malformed 'select_metadata' parameter raised JMESPathError: {err}", ) @@ -230,7 +238,8 @@ async def distinct( ) else: raise HTTPException( - status_code=405, detail="This node does not support distinct." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not support distinct.", ) @@ -331,7 +340,7 @@ async def metadata( ) except JMESPathError as err: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"Malformed 'select_metadata' parameter raised JMESPathError: {err}", ) meta = {"root_path": request.scope.get("root_path") or "/"} if root_path else {} @@ -367,7 +376,7 @@ async def array_block( ndim = len(shape) if len(block) != ndim: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Block parameter must have {ndim} comma-separated parameters, " f"corresponding to the dimensions of this {ndim}-dimensional array." @@ -377,7 +386,7 @@ async def array_block( # Handle special case of numpy scalar. if shape != (): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"Requested scalar but shape is {entry.structure().shape}", ) with record_timing(request.state.metrics, "read"): @@ -387,15 +396,17 @@ async def array_block( with record_timing(request.state.metrics, "read"): array = await ensure_awaitable(entry.read_block, block, slice) except IndexError: - raise HTTPException(status_code=400, detail="Block index out of range") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail="Block index out of range" + ) if (expected_shape is not None) and (expected_shape != array.shape): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"The expected_shape {expected_shape} does not match the actual shape {array.shape}", ) if array.nbytes > settings.response_bytesize_limit: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Use slicing ('?slice=...') to request smaller chunks." @@ -416,7 +427,7 @@ async def array_block( ) except UnsupportedMediaTypes as err: # raise HTTPException(status_code=406, detail=", ".join(err.supported)) - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -449,15 +460,17 @@ async def array_full( if structure_family == StructureFamily.array: array = numpy.asarray(array) # Force dask or PIMS or ... to do I/O. except IndexError: - raise HTTPException(status_code=400, detail="Block index out of range") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail="Block index out of range" + ) if (expected_shape is not None) and (expected_shape != array.shape): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"The expected_shape {expected_shape} does not match the actual shape {array.shape}", ) if array.nbytes > settings.response_bytesize_limit: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Use slicing ('?slice=...') to request smaller chunks." @@ -477,7 +490,7 @@ async def array_full( filename=filename, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -507,7 +520,9 @@ async def get_table_partition( "Include these query values using only the 'column' parameter.", ) ) - raise HTTPException(status_code=400, detail=redundant_field_and_column) + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=redundant_field_and_column + ) elif field is not None: field_is_deprecated = " ".join( ( @@ -577,13 +592,17 @@ async def table_partition( with record_timing(request.state.metrics, "read"): df = await ensure_awaitable(entry.read_partition, partition, column) except IndexError: - raise HTTPException(status_code=400, detail="Partition out of range") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail="Partition out of range" + ) except KeyError as err: (key,) = err.args - raise HTTPException(status_code=400, detail=f"No such field {key}.") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f"No such field {key}." + ) if df.memory_usage().sum() > settings.response_bytesize_limit: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Select a subset of the columns ('?field=...') to " @@ -604,7 +623,7 @@ async def table_partition( filename=filename, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -680,10 +699,12 @@ async def table_full( data = await ensure_awaitable(entry.read, column) except KeyError as err: (key,) = err.args - raise HTTPException(status_code=400, detail=f"No such field {key}.") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f"No such field {key}." + ) if data.memory_usage().sum() > settings.response_bytesize_limit: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Select a subset of the columns to " @@ -705,7 +726,7 @@ async def table_full( filter_for_access=None, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -785,7 +806,9 @@ async def container_full( data = await ensure_awaitable(entry.read, fields=field) except KeyError as err: (key,) = err.args - raise HTTPException(status_code=400, detail=f"No such field {key}.") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f"No such field {key}." + ) curried_filter = partial( filter_for_access, principal=principal, @@ -808,7 +831,7 @@ async def container_full( filter_for_access=curried_filter, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -838,12 +861,14 @@ async def node_full( data = await ensure_awaitable(entry.read, field) except KeyError as err: (key,) = err.args - raise HTTPException(status_code=400, detail=f"No such field {key}.") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, detail=f"No such field {key}." + ) if (entry.structure_family == StructureFamily.table) and ( data.memory_usage().sum() > settings.response_bytesize_limit ): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Select a subset of the columns ('?field=...') to " @@ -875,7 +900,7 @@ async def node_full( filter_for_access=curried_filter, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -972,7 +997,7 @@ async def _awkward_buffers( > settings.response_bytesize_limit ): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Use slicing ('?slice=...') to request smaller chunks." @@ -993,7 +1018,7 @@ async def _awkward_buffers( filename=filename, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.get( @@ -1027,7 +1052,7 @@ async def awkward_full( array = awkward.from_buffers(*components) if array.nbytes > settings.response_bytesize_limit: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( f"Response would exceed {settings.response_bytesize_limit}. " "Use slicing ('?slice=...') to request smaller chunks." @@ -1047,7 +1072,7 @@ async def awkward_full( filename=filename, ) except UnsupportedMediaTypes as err: - raise HTTPException(status_code=406, detail=err.args[0]) + raise HTTPException(status_code=HTTP_406_NOT_ACCEPTABLE, detail=err.args[0]) @router.post("/metadata/{path:path}", response_model=schemas.PostMetadataResponse) @@ -1067,7 +1092,8 @@ async def post_metadata( ) if body.data_sources and not getattr(entry, "writable", False): raise HTTPException( - status_code=405, detail=f"Data cannot be written at the path {path}" + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail=f"Data cannot be written at the path {path}", ) return await _create_node( request=request, @@ -1132,7 +1158,8 @@ async def _create_node( if spec.name not in validation_registry: if settings.reject_undeclared_specs: raise HTTPException( - status_code=400, detail=f"Unrecognized spec: {spec.name}" + status_code=HTTP_400_BAD_REQUEST, + detail=f"Unrecognized spec: {spec.name}", ) else: validator = validation_registry(spec.name) @@ -1140,7 +1167,7 @@ async def _create_node( result = validator(metadata, structure_family, structure, spec) except ValidationError as e: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"failed validation for spec {spec.name}:\n{e}", ) if result is not None: @@ -1176,7 +1203,8 @@ async def delete( await entry.delete() else: raise HTTPException( - status_code=405, detail="This node does not support deletion." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not support deletion.", ) return json_or_msgpack(request, None) @@ -1190,7 +1218,8 @@ async def bulk_delete( await entry.delete_tree() else: raise HTTPException( - status_code=405, detail="This node does not support bulk deletion." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not support bulk deletion.", ) return json_or_msgpack(request, None) @@ -1207,7 +1236,8 @@ async def put_array_full( body = await request.body() if not hasattr(entry, "write"): raise HTTPException( - status_code=405, detail="This node cannot accept array data." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node cannot accept array data.", ) media_type = request.headers["content-type"] if entry.structure_family == "array": @@ -1236,7 +1266,8 @@ async def put_array_block( ): if not hasattr(entry, "write_block"): raise HTTPException( - status_code=405, detail="This node cannot accept array data." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node cannot accept array data.", ) from tiled.adapters.array import slice_and_shape_from_block_and_chunks @@ -1269,7 +1300,8 @@ async def put_node_full( ): if not hasattr(entry, "write"): raise HTTPException( - status_code=405, detail="This node does not support writing." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not support writing.", ) body = await request.body() media_type = request.headers["content-type"] @@ -1288,7 +1320,8 @@ async def put_table_partition( ): if not hasattr(entry, "write_partition"): raise HTTPException( - status_code=405, detail="This node does not supporting writing a partition." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not supporting writing a partition.", ) body = await request.body() media_type = request.headers["content-type"] @@ -1327,7 +1360,10 @@ async def put_awkward_full( ): body = await request.body() if not hasattr(entry, "write"): - raise HTTPException(status_code=405, detail="This node cannot be written to.") + raise HTTPException( + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node cannot be written to.", + ) media_type = request.headers["content-type"] deserializer = deserialization_registry.dispatch( StructureFamily.awkward, media_type @@ -1348,7 +1384,8 @@ async def put_metadata( ): if not hasattr(entry, "update_metadata"): raise HTTPException( - status_code=405, detail="This node does not support update of metadata." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not support update of metadata.", ) metadata, structure_family, structure, specs = ( @@ -1372,7 +1409,8 @@ async def put_metadata( if spec.name not in validation_registry: if settings.reject_undeclared_specs: raise HTTPException( - status_code=400, detail=f"Unrecognized spec: {spec.name}" + status_code=HTTP_400_BAD_REQUEST, + detail=f"Unrecognized spec: {spec.name}", ) else: validator = validation_registry(spec.name) @@ -1380,7 +1418,7 @@ async def put_metadata( result = validator(metadata, structure_family, structure, spec) except ValidationError as e: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=f"failed validation for spec {spec.name}:\n{e}", ) if result is not None: @@ -1407,7 +1445,8 @@ async def get_revisions( ): if not hasattr(entry, "revisions"): raise HTTPException( - status_code=405, detail="This node does not support revisions." + status_code=HTTP_405_METHOD_NOT_ALLOWED, + detail="This node does not support revisions.", ) base_url = get_base_url(request) @@ -1431,7 +1470,7 @@ async def delete_revision( ): if not hasattr(entry, "revisions"): raise HTTPException( - status_code=405, + status_code=HTTP_405_METHOD_NOT_ALLOWED, detail="This node does not support a del request for revisions.", ) @@ -1449,7 +1488,7 @@ async def get_asset( ): if not settings.expose_raw_assets: raise HTTPException( - status_code=403, + status_code=HTTP_403_FORBIDDEN, detail=( "This Tiled server is configured not to allow " "downloading raw assets." @@ -1457,20 +1496,20 @@ async def get_asset( ) if not hasattr(entry, "asset_by_id"): raise HTTPException( - status_code=405, + status_code=HTTP_405_METHOD_NOT_ALLOWED, detail="This node does not support downloading assets.", ) asset = await entry.asset_by_id(id) if asset is None: raise HTTPException( - status_code=404, + status_code=HTTP_404_NOT_FOUND, detail=f"This node exists but it does not have an Asset with id {id}", ) if asset.is_directory: if relative_path is None: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail=( "This asset is a directory. Must specify relative path, " f"from manifest provided by /asset/manifest/...?id={id}" @@ -1478,18 +1517,18 @@ async def get_asset( ) if relative_path.is_absolute(): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail="relative_path query parameter must be a *relative* path", ) else: if relative_path is not None: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail="This asset is not a directory. The relative_path query parameter must not be set.", ) if not asset.data_uri.startswith("file:"): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail="Only download assets stored as file:// is currently supported.", ) path = path_from_uri(asset.data_uri) @@ -1511,7 +1550,7 @@ async def get_asset( full_path, stat_result=stat_result, method="GET", - status_code=200, + status_code=HTTP_200_OK, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @@ -1525,7 +1564,7 @@ async def get_asset_manifest( ): if not settings.expose_raw_assets: raise HTTPException( - status_code=403, + status_code=HTTP_403_FORBIDDEN, detail=( "This Tiled server is configured not to allow " "downloading raw assets." @@ -1533,24 +1572,24 @@ async def get_asset_manifest( ) if not hasattr(entry, "asset_by_id"): raise HTTPException( - status_code=405, + status_code=HTTP_405_METHOD_NOT_ALLOWED, detail="This node does not support downloading assets.", ) asset = await entry.asset_by_id(id) if asset is None: raise HTTPException( - status_code=404, + status_code=HTTP_404_NOT_FOUND, detail=f"This node exists but it does not have an Asset with id {id}", ) if not asset.is_directory: raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail="This asset is not a directory. There is no manifest.", ) if not asset.data_uri.startswith("file:"): raise HTTPException( - status_code=400, + status_code=HTTP_400_BAD_REQUEST, detail="Only download assets stored as file:// is currently supported.", ) path = path_from_uri(asset.data_uri)