diff --git a/docs/api.md b/docs/api.md index e09ef1f6..683abc9e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,6 +9,8 @@ For routine same-host reads and writes, prefer `dp3 sh` or the generated `/get`](#get-entities): get current snapshots of entities of entity type @@ -22,6 +24,27 @@ There are several API endpoints: - [`GET /entity//_/distinct/`](#get-distinct-values): get distinct attribute values and their counts based on latest snapshots - [`DELETE /entity//`](#delete-eid-data): delete entity data for given id - [`POST /entity///ttl`](#extend-ttls): extend TTLs of the specified entity + +**Note:** The v1 API has a limitation where Entity IDs (EIDs) containing `/` characters (e.g., IPv6 CIDR notation like `2001:db8:f00::/64`) cannot be accessed via the REST API because `/` is interpreted as a path separator. For such EIDs, use the v2 API below. + +### v2 API (Query-parameter EIDs) + +The v2 API addresses the limitation of EIDs containing special characters by passing the EID as a query parameter instead of in the URL path. + +- [`GET /v2/entity//get`](#v2-get-entities): list entities (same as v1) +- [`GET /v2/entity//count`](#v2-count-entities): count entities (same as v1) +- [`GET /v2/entity//raw/get`](#v2-get-raw-datapoints): get raw datapoints (same as v1) +- [`GET /v2/entity//_/distinct/`](#v2-get-distinct-values): get distinct values (same as v1) +- [`GET /v2/entity//`](#v2-get-eid-data): get entity data with `?eid=X` +- [`GET /v2/entity//master`](#v2-get-master-record): get master record with `?eid=X` +- [`GET /v2/entity//snapshots`](#v2-get-snapshots): get snapshots with `?eid=X` +- [`GET /v2/entity//attr`](#v2-get-attr-value): get attribute with `?eid=X&attr=name` +- [`POST /v2/entity//attr`](#v2-set-attr-value): set attribute with `?eid=X&attr=name` +- [`POST /v2/entity//ttl`](#v2-extend-ttls): extend TTL with `?eid=X` +- [`DELETE /v2/entity//`](#v2-delete-eid-data): delete entity with `?eid=X` + +### Other Endpoints + - [`GET /entities`](#entities): list entity configuration - [`GET /control/`](#control): send a pre-defined action into execution queue. - [`GET /telemetry/sources_validity`](#source-validity): get timestamps of latest data from each source @@ -760,3 +783,256 @@ Only queues belonging to the current DP³ application are returned. ] } ``` + +--- + +## v2 API: Query-Parameter EIDs + +The v2 API provides an alternative way to access entities where the Entity ID (EID) is passed as a query parameter instead of in the URL path. This allows EIDs containing special characters like `/` (e.g., IPv6 CIDR notation `2001:db8:f00::/64`). + +### Why v2 API? + +In the v1 API, EIDs are part of the URL path: +``` +GET /entity/ipv6_64prefix/2001:db8:f00::/64/snapshots +``` + +This fails because `/64` is interpreted as a new path segment, causing a 404 error. + +In the v2 API, the EID is a query parameter: +``` +GET /v2/entity/ipv6_64prefix/snapshots?eid=2001:db8:f00::/64 +``` + +This works correctly because the entire EID value is passed as a query string. Query +parameter values still need to be URL-encoded as usual; the examples below let `curl` handle +encoding rather than constructing query strings manually. + +--- + +### v2 Get Entities + +Same as v1 `/entity/{etype}/get`. See v1 documentation above. + +### v2 Count Entities + +Same as v1 `/entity/{etype}/count`. See v1 documentation above. + +### v2 Get Raw Datapoints + +Same as v1 `/entity/{etype}/raw/get`. See v1 documentation above. + +### v2 Get Distinct Values + +Same as v1 `/entity/{etype}/_/distinct/{attr}`. See v1 documentation above. + +--- + +### v2 Get EID Data + +Get data of an entity identified by `etype` and `eid`. + +#### Request + +`GET /v2/entity/{etype}/?eid=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID (can contain special characters like `/`) | Yes | +| `date_from` | Start date for filtering (ISO 8601) | No | +| `date_to` | End date for filtering (ISO 8601) | No | + +#### Example + +```bash +# IPv6 CIDR notation EID +curl --get "http://localhost:8000/v2/entity/ipv6_64prefix/" \ + --data-urlencode "eid=2001:db8:f00::/64" + +# Path-like EID +curl --get "http://localhost:8000/v2/entity/user/" \ + --data-urlencode "eid=admin/root" +``` + +#### Response + +**`200 OK`**: Same structure as v1 `/entity/{etype}/{eid}` response. + +--- + +### v2 Get Master Record + +Get the master record of an entity. + +#### Request + +`GET /v2/entity/{etype}/master?eid=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID | Yes | +| `date_from` | Start date for filtering | No | +| `date_to` | End date for filtering | No | + +#### Example + +```bash +curl --get "http://localhost:8000/v2/entity/ipv6_64prefix/master" \ + --data-urlencode "eid=2001:db8:f00::/64" +``` + +--- + +### v2 Get Snapshots + +Get snapshot history of an entity. + +#### Request + +`GET /v2/entity/{etype}/snapshots?eid=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID | Yes | +| `date_from` | Start date for filtering | No | +| `date_to` | End date for filtering | No | +| `skip` | Number of snapshots to skip (pagination) | No, default=0 | +| `limit` | Maximum snapshots to return (0=no limit) | No, default=0 | + +#### Example + +```bash +curl --get "http://localhost:8000/v2/entity/ipv6_64prefix/snapshots" \ + --data-urlencode "eid=2001:db8:f00::/64" \ + --data-urlencode "limit=10" +``` + +--- + +### v2 Get Attribute Value + +Get attribute value for an entity. + +#### Request + +`GET /v2/entity/{etype}/attr?eid=&attr=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID | Yes | +| `attr` | Attribute name | Yes | +| `date_from` | Start date for history | No | +| `date_to` | End date for history | No | + +#### Example + +```bash +curl --get "http://localhost:8000/v2/entity/ipv6_64prefix/attr" \ + --data-urlencode "eid=2001:db8:f00::/64" \ + --data-urlencode "attr=network" +``` + +--- + +### v2 Set Attribute Value + +Set attribute value for an entity. + +#### Request + +`POST /v2/entity/{etype}/attr?eid=&attr=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID | Yes | +| `attr` | Attribute name | Yes | + +#### Request Body + +```json +{ + "value": "new_value" +} +``` + +#### Example + +```bash +curl --request POST "http://localhost:8000/v2/entity/example/attr" \ + --url-query "eid=test_id" \ + --url-query "attr=hostname" \ + --header "Content-Type: application/json" \ + --data '{"value": "new_hostname"}' +``` + +--- + +### v2 Extend TTLs + +Extend TTLs of an entity. + +#### Request + +`POST /v2/entity/{etype}/ttl?eid=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID | Yes | + +#### Request Body + +```json +{ + "default": "2025-12-31T23:59:59Z" +} +``` + +#### Example + +```bash +curl --request POST "http://localhost:8000/v2/entity/example/ttl" \ + --url-query "eid=test_id" \ + --header "Content-Type: application/json" \ + --data '{"default": "2025-12-31T23:59:59Z"}' +``` + +--- + +### v2 Delete Entity Data + +Delete the master record and snapshots of an entity. + +#### Request + +`DELETE /v2/entity/{etype}/?eid=` + +#### Query Parameters + +| Parameter | Description | Required | +|-----------|-------------|----------| +| `eid` | Entity ID | Yes | + +#### Example + +```bash +curl --request DELETE "http://localhost:8000/v2/entity/example/" \ + --url-query "eid=test_id" +``` + +--- + +### v2 API Testing + +See [`tests/test_api/test_v2_api.py`](../tests/test_api/test_v2_api.py) for comprehensive test examples. diff --git a/dp3/api/main.py b/dp3/api/main.py index 3eb1fda7..e8c13228 100644 --- a/dp3/api/main.py +++ b/dp3/api/main.py @@ -48,6 +48,7 @@ async def validation_exception_handler(request, exc): ) # Register routers +app.include_router(entity.v2_router, prefix="/v2/entity", tags=["Entity v2"]) app.include_router(entity.router, prefix="/entity", tags=["Entity"]) app.include_router(control.router, prefix="/control", tags=["Control"]) app.include_router(telemetry.router, prefix="/telemetry", tags=["Telemetry"]) diff --git a/dp3/api/routers/entity.py b/dp3/api/routers/entity.py index 3b2ed28e..ccddcc27 100644 --- a/dp3/api/routers/entity.py +++ b/dp3/api/routers/entity.py @@ -1,5 +1,6 @@ import re from datetime import UTC, datetime +from inspect import cleandoc from typing import Annotated, Any, cast from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -29,14 +30,14 @@ async def check_etype(etype: str): - """Middleware to check entity type existence""" + """Validate the entity type as a FastAPI dependency.""" if etype not in MODEL_SPEC.entities: raise RequestValidationError(["path", "etype"], f"Entity type '{etype}' doesn't exist") return etype async def parse_eid(etype: str, eid: str) -> EntityId: - """Middleware to parse EID""" + """Parse a path EID as a FastAPI dependency.""" try: return cast(EntityId, EntityIdAdapter.validate_python({"etype": etype, "eid": eid})) except ValidationError as e: @@ -46,6 +47,19 @@ async def parse_eid(etype: str, eid: str) -> EntityId: ParsedEid = Annotated[EntityId, Depends(parse_eid)] +async def parse_eid_from_query( + etype: str, eid: Annotated[str, Query(description="Entity ID")] +) -> EntityId: + """Parse EID from query parameter (for v2 API).""" + try: + return cast(EntityId, EntityIdAdapter.validate_python({"etype": etype, "eid": eid})) + except ValidationError as e: + raise RequestValidationError(["query", "eid"], e.errors()[0]["msg"]) from e + + +ParsedQueryEid = Annotated[EntityId, Depends(parse_eid_from_query)] + + def _parse_optional_eid(etype: str, eid: str | None) -> Any: """Parse optional entity id query parameter for entity-scoped endpoints.""" if eid is None: @@ -112,7 +126,90 @@ def get_eid_snapshots_handler( ) +def get_eid_data_handler( + e: EntityId, + date_from: AwareDatetime | None = None, + date_to: AwareDatetime | None = None, +) -> EntityEidData: + """Handler for getting all data of EID.""" + master_record = get_eid_master_record_handler(e, date_from, date_to) + snapshots = get_eid_snapshots_handler(e, date_from, date_to) + return EntityEidData( + empty=not master_record and len(snapshots) == 0, + master_record=master_record, + snapshots=snapshots, + ) + + +def get_eid_attr_value_handler( + e: EntityId, + attr: str, + attr_location: str, + date_from: AwareDatetime | None = None, + date_to: AwareDatetime | None = None, +) -> EntityEidAttrValueOrHistory: + """Handler for getting an attribute value or history of EID.""" + if attr not in MODEL_SPEC.attribs(e.type): + raise RequestValidationError([attr_location, "attr"], f"Attribute '{attr}' doesn't exist") + + value_or_history = DB.get_value_or_history(e.type, attr, e.id, t1=date_from, t2=date_to) + return EntityEidAttrValueOrHistory( + attr_type=MODEL_SPEC.attr(e.type, attr).t, **value_or_history + ) + + +async def set_eid_attr_value_handler( + e: EntityId, attr: str, attr_location: str, request: Request +) -> SuccessResponse: + """Handler for setting the current value of an EID attribute.""" + if attr not in MODEL_SPEC.attribs(e.type): + raise RequestValidationError([attr_location, "attr"], f"Attribute '{attr}' doesn't exist") + + try: + body = EntityEidAttrValue.model_validate(await request.json()) + except ValueError as exc: + raise RequestValidationError(["body"], str(exc)) from exc + + try: + datapoint = DataPoint( + type=e.type, + id=e.id, + attr=attr, + v=body.value, + t1=datetime.now(UTC), + src=f"{request.client.host} via API", + ) + dp3_datapoint = api_to_dp3_datapoint(datapoint.model_dump()) + except ValidationError as exc: + raise RequestValidationError(["body", "value"], exc.errors()[0]["msg"]) from exc + + with task_context(MODEL_SPEC): + task = DataPointTask(etype=e.type, eid=e.id, data_points=[dp3_datapoint]) + + TASK_WRITER.put_task(task, False) + return SuccessResponse() + + +def extend_eid_ttls_handler(e: EntityId, body: dict[str, AwareDatetime]) -> SuccessResponse: + """Handler for extending TTLs of EID.""" + with task_context(MODEL_SPEC): + task = DataPointTask(etype=e.type, eid=e.id, ttl_tokens=body) + + TASK_WRITER.put_task(task, False) + return SuccessResponse() + + +def delete_eid_record_handler(e: EntityId) -> SuccessResponse: + """Handler for deleting the master record and snapshots of EID.""" + with task_context(MODEL_SPEC): + task = DataPointTask(etype=e.type, eid=e.id, delete=True) + + TASK_WRITER.put_task(task, False) + return SuccessResponse() + + router = APIRouter(dependencies=[Depends(check_etype)]) +v2_router = APIRouter(dependencies=[Depends(check_etype)]) # Compile regex patterns once at module level for reuse @@ -225,6 +322,11 @@ def _validate_sort_params(etype: str, sort: list[str] | None) -> list[tuple[str, "/{etype}/get", responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, ) +@v2_router.get( + "/{etype}/get", + name="v2_get_entity_type_eids", + responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, +) async def get_entity_type_eids( etype: str, fulltext_filters: Json = None, @@ -352,6 +454,11 @@ async def get_entity_type_eids( "/{etype}/count", responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, ) +@v2_router.get( + "/{etype}/count", + name="v2_count_entity_type_eids", + responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, +) async def count_entity_type_eids( etype: str, fulltext_filters: Json = None, @@ -379,6 +486,11 @@ async def count_entity_type_eids( "/{etype}/raw/get", responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, ) +@v2_router.get( + "/{etype}/raw/get", + name="v2_get_entity_type_raw_datapoints", + responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, +) async def get_entity_type_raw_datapoints( etype: str, eid: str | None = None, @@ -422,15 +534,9 @@ async def get_eid_data( Contains all snapshots and master record. Snapshots are ordered by ascending creation time. - Combines function of `/{etype}/{eid}/master` and `/{etype}/{eid}/snapshots`. + Combines the master-record and snapshots endpoints. """ - master_record = get_eid_master_record_handler(e, date_from, date_to) - snapshots = get_eid_snapshots_handler(e, date_from, date_to) - - # Whether this eid contains any data - empty = not master_record and len(snapshots) == 0 - - return EntityEidData(empty=empty, master_record=master_record, snapshots=snapshots) + return get_eid_data_handler(e, date_from, date_to) @router.get("/{etype}/{eid}/master") @@ -471,60 +577,18 @@ async def get_eid_attr_value( - current value and history: in case of observation attribute - history: in case of timeseries attribute """ - # Check if attribute exists - if attr not in MODEL_SPEC.attribs(e.type): - raise RequestValidationError(["path", "attr"], f"Attribute '{attr}' doesn't exist") - - value_or_history = DB.get_value_or_history(e.type, attr, e.id, t1=date_from, t2=date_to) - - return EntityEidAttrValueOrHistory( - attr_type=MODEL_SPEC.attr(e.type, attr).t, **value_or_history - ) + return get_eid_attr_value_handler(e, attr, "path", date_from, date_to) @router.post("/{etype}/{eid}/set/{attr}") -async def set_eid_attr_value(etype: str, eid: str, attr: str, request: Request) -> SuccessResponse: +async def set_eid_attr_value(e: ParsedEid, attr: str, request: Request) -> SuccessResponse: """Set current value of attribute Internally just creates datapoint for specified attribute and value. This endpoint is meant for `editable` plain attributes -- for direct user edit on DP3 web UI. """ - # Check if attribute exists - if attr not in MODEL_SPEC.attribs(etype): - raise RequestValidationError(["path", "attr"], f"Attribute '{attr}' doesn't exist") - - try: - body = EntityEidAttrValue.model_validate(await request.json()) - except ValueError as e: - raise RequestValidationError(["body"], str(e)) from e - - # Construct datapoint - try: - dp = DataPoint( - type=etype, - id=eid, - attr=attr, - v=body.value, - t1=datetime.now(UTC), - src=f"{request.client.host} via API", - ) - dp3_dp = api_to_dp3_datapoint(dp.model_dump()) - except ValidationError as e: - raise RequestValidationError(["body", "value"], e.errors()[0]["msg"]) from e - - # This shouldn't fail - with task_context(MODEL_SPEC): - task = DataPointTask(etype=etype, eid=eid, data_points=[dp3_dp]) - - # Push tasks to task queue - TASK_WRITER.put_task(task, False) - - # Datapoints from this endpoint are intentionally not logged using `DPLogger`. - # If for some reason, in the future, they need to be, just copy code from data ingestion - # endpoint. - - return SuccessResponse() + return await set_eid_attr_value_handler(e, attr, "path", request) @router.get( @@ -547,14 +611,7 @@ async def get_distinct_attribute_values(etype: str, attr: str) -> dict[JsonVal, @router.post("/{etype}/{eid}/ttl") async def extend_eid_ttls(e: ParsedEid, body: dict[str, AwareDatetime]) -> SuccessResponse: """Extend TTLs of the specified entity""" - # Construct task - with task_context(MODEL_SPEC): - task = DataPointTask(etype=e.type, eid=e.id, ttl_tokens=body) - - # Push tasks to task queue - TASK_WRITER.put_task(task, False) - - return SuccessResponse() + return extend_eid_ttls_handler(e, body) @router.delete("/{etype}/{eid}") @@ -564,9 +621,87 @@ async def delete_eid_record(e: ParsedEid) -> SuccessResponse: Notice that this does not delete any raw datapoints, or block the re-creation of the entity if new datapoints are received. """ - # Create a "delete" task and push it to task queue - with task_context(MODEL_SPEC): - task = DataPointTask(etype=e.type, eid=e.id, delete=True) - TASK_WRITER.put_task(task, False) + return delete_eid_record_handler(e) - return SuccessResponse() + +_V2_EID_QUERY_DESCRIPTION = ( + "**v2 API**: EID is passed as query parameter (`?eid=X`) instead of path.\n" + "This allows EIDs containing special characters like `/` (e.g., IPv6 CIDR notation)." +) + + +def _v2desc(v1_endpoint: Any) -> str: + """Combine v1 endpoint documentation with the v2 EID transport note.""" + return f"{cleandoc(v1_endpoint.__doc__ or '')}\n\n{_V2_EID_QUERY_DESCRIPTION}" + + +@v2_router.get("/{etype}", include_in_schema=False, description=_v2desc(get_eid_data)) +@v2_router.get("/{etype}/", description=_v2desc(get_eid_data)) +async def v2_get_eid_data( + e: ParsedQueryEid, + date_from: AwareDatetime | None = None, + date_to: AwareDatetime | None = None, +) -> EntityEidData: + return get_eid_data_handler(e, date_from, date_to) + + +@v2_router.get("/{etype}/master", description=_v2desc(get_eid_master_record)) +async def v2_get_eid_master_record( + e: ParsedQueryEid, + date_from: AwareDatetime | None = None, + date_to: AwareDatetime | None = None, +) -> EntityEidMasterRecord: + return get_eid_master_record_handler(e, date_from, date_to) + + +@v2_router.get("/{etype}/snapshots", description=_v2desc(get_eid_snapshots)) +async def v2_get_eid_snapshots( + e: ParsedQueryEid, + date_from: AwareDatetime | None = None, + date_to: AwareDatetime | None = None, + skip: NonNegativeInt = 0, + limit: NonNegativeInt = 0, +) -> EntityEidSnapshots: + return get_eid_snapshots_handler(e, date_from, date_to, skip, limit) + + +@v2_router.get("/{etype}/attr", description=_v2desc(get_eid_attr_value)) +async def v2_get_eid_attr_value( + e: ParsedQueryEid, + attr: Annotated[str, Query(description="Attribute name")], + date_from: AwareDatetime | None = None, + date_to: AwareDatetime | None = None, +) -> EntityEidAttrValueOrHistory: + return get_eid_attr_value_handler(e, attr, "query", date_from, date_to) + + +@v2_router.post("/{etype}/attr", description=_v2desc(set_eid_attr_value)) +async def v2_set_eid_attr_value( + e: ParsedQueryEid, + attr: Annotated[str, Query(description="Attribute name")], + request: Request, +) -> SuccessResponse: + return await set_eid_attr_value_handler(e, attr, "query", request) + + +v2_router.add_api_route( + "/{etype}/_/distinct/{attr}", + get_distinct_attribute_values, + methods=["GET"], + name="v2_get_distinct_attribute_values", + responses={400: {"description": "Query can't be processed", "model": ErrorResponse}}, +) + + +@v2_router.post("/{etype}/ttl", description=_v2desc(extend_eid_ttls)) +async def v2_extend_eid_ttls( + e: ParsedQueryEid, + body: dict[str, AwareDatetime], +) -> SuccessResponse: + return extend_eid_ttls_handler(e, body) + + +@v2_router.delete("/{etype}", include_in_schema=False, description=_v2desc(delete_eid_record)) +@v2_router.delete("/{etype}/", description=_v2desc(delete_eid_record)) +async def v2_delete_eid_record(e: ParsedQueryEid) -> SuccessResponse: + return delete_eid_record_handler(e) diff --git a/tests/test_api/test_v2_api.py b/tests/test_api/test_v2_api.py new file mode 100644 index 00000000..22c318e7 --- /dev/null +++ b/tests/test_api/test_v2_api.py @@ -0,0 +1,123 @@ +"""Integration tests for v2 API endpoints with query-parameter EIDs.""" + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +import common +import requests + + +class V2APIIntegration(common.APITest): + """Verify that v2 routes preserve EIDs containing path separators.""" + + etype = "test_entity_type" + attr = "test_attr_string" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.eid = f"v2/path/{uuid4()}" + cls.initial_value = "v2 initial value" + + response = cls.push_datapoints( + [ + { + "type": cls.etype, + "id": cls.eid, + "attr": cls.attr, + "v": cls.initial_value, + "src": "v2-api-test", + } + ] + ) + if response.status_code != 200: + raise RuntimeError(f"Failed to push v2 API test datapoint: {response.text}") + + @classmethod + def v2_url(cls, suffix: str = "") -> str: + return f"{common.base_url}/v2/entity/{cls.etype}{suffix}" + + @classmethod + def get_v2(cls, suffix: str = "", **params) -> requests.Response: + return common.retry_request_on_error( + lambda: requests.get(cls.v2_url(suffix), params=params, timeout=5) + ) + + @classmethod + def post_v2(cls, suffix: str, json, **params) -> requests.Response: + return common.retry_request_on_error( + lambda: requests.post(cls.v2_url(suffix), params=params, json=json, timeout=5) + ) + + @classmethod + def delete_v2(cls, suffix: str = "", **params) -> requests.Response: + return common.retry_request_on_error( + lambda: requests.delete(cls.v2_url(suffix), params=params, timeout=5) + ) + + def test_01_get_entity_data_with_trailing_slash(self): + response = self.query_expected_value( + lambda: self.get_v2("/", eid=self.eid), + lambda result: result.status_code == 200 + and result.json()["master_record"].get(self.attr, {}).get("v") == self.initial_value, + msg="Timed out waiting for the v2 entity data endpoint.", + ) + self.assertEqual(response.json()["master_record"][self.attr]["v"], self.initial_value) + + def test_02_get_entity_data_without_trailing_slash(self): + response = self.get_v2(eid=self.eid) + self.assertEqual(response.status_code, 200, msg=response.text) + self.assertEqual(response.json()["master_record"][self.attr]["v"], self.initial_value) + + def test_03_get_master_record(self): + response = self.get_v2("/master", eid=self.eid) + self.assertEqual(response.status_code, 200, msg=response.text) + self.assertEqual(response.json()[self.attr]["v"], self.initial_value) + + def test_04_get_snapshots(self): + response = self.get_v2("/snapshots", eid=self.eid) + self.assertEqual(response.status_code, 200, msg=response.text) + self.assertIsInstance(response.json(), list) + + def test_05_get_attribute(self): + response = self.get_v2("/attr", eid=self.eid, attr=self.attr) + self.assertEqual(response.status_code, 200, msg=response.text) + self.assertEqual(response.json()["current_value"], self.initial_value) + + def test_06_set_attribute(self): + updated_value = "v2 updated value" + response = self.post_v2("/attr", {"value": updated_value}, eid=self.eid, attr=self.attr) + self.assertEqual(response.status_code, 200, msg=response.text) + + self.query_expected_value( + lambda: self.get_v2("/attr", eid=self.eid, attr=self.attr), + lambda result: result.status_code == 200 + and result.json().get("current_value") == updated_value, + msg="Timed out waiting for the v2 attribute update.", + ) + + def test_07_extend_ttl(self): + future_time = (datetime.now(UTC) + timedelta(hours=1)).isoformat() + response = self.post_v2("/ttl", {"manual": future_time}, eid=self.eid) + self.assertEqual(response.status_code, 200, msg=response.text) + + def test_08_missing_eid_returns_422(self): + response = self.get_v2("/master") + self.assertEqual(response.status_code, 422, msg=response.text) + + def test_09_nonexistent_etype_returns_422(self): + response = common.retry_request_on_error( + lambda: requests.get( + f"{common.base_url}/v2/entity/nonexistent_type/master", + params={"eid": self.eid}, + timeout=5, + ) + ) + self.assertEqual(response.status_code, 422, msg=response.text) + + def test_99_delete_entity_with_and_without_trailing_slash(self): + response = self.delete_v2(eid=self.eid) + self.assertEqual(response.status_code, 200, msg=response.text) + + response = self.delete_v2("/", eid=self.eid) + self.assertEqual(response.status_code, 200, msg=response.text)