From 0eabdc8b02c615ed81d9726fc2d9f4e04426ccd9 Mon Sep 17 00:00:00 2001 From: andreas Date: Mon, 4 May 2026 17:04:55 +0200 Subject: [PATCH 1/2] 1161: Moved self refrernce from obj2resource to getManyResources + basic test coverage --- ci/apiv2/test_http_methods.py | 47 +++++++++++++++++++++-- src/inc/apiv2/common/AbstractBaseAPI.php | 10 ++--- src/inc/apiv2/common/AbstractModelAPI.php | 4 ++ 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/ci/apiv2/test_http_methods.py b/ci/apiv2/test_http_methods.py index d70e2d47b..70da60e0d 100644 --- a/ci/apiv2/test_http_methods.py +++ b/ci/apiv2/test_http_methods.py @@ -5,9 +5,13 @@ class HttpMethodsTest(BaseTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.config = HashtopolisConfig() + def test_empty_body(self): - config = HashtopolisConfig() - conn = HashtopolisConnector('/ui/users', config) + conn = HashtopolisConnector('/ui/users', self.config) conn.authenticate() headers = conn._headers @@ -19,4 +23,41 @@ def test_empty_body(self): self.assertGreaterEqual(len(values), 1) # TODO: Test for non-empty body which should fail - # TODO: Test for invalid parameters \ No newline at end of file + # TODO: Test for invalid parameters + + def test_get_one_response_should_not_duplicate_self_reference_in_data(self): + conn = HashtopolisConnector('/ui/users', self.config) + conn.authenticate() + resource_path = conn._model_uri + "/1" + uri = conn._api_endpoint + resource_path + + response = requests.get(uri, headers=conn._headers) + r = response.json() + + self.assertIsNotNone(r['links']['self'], "Top level self reference should be present in all responses.") + self.assertIn(response.request.path_url, r['links']['self'], "Self reference for a single resource should be its path.") + self.assertTrue(isinstance(r['data'], dict), "A single resource should be represented as an object.") + self.assertNotIn('self', r['data'].get('links', {}), "A single resource should not include a self reference.") + + def test_get_many_response_should_include_self_reference_for_every_resource(self): + conn = HashtopolisConnector('/ui/hashtypes', self.config) + conn.authenticate() + resource_path = conn._model_uri + uri = conn._api_endpoint + resource_path + + response = requests.get(uri, headers=conn._headers) + r = response.json() + + self.assertIsNotNone(r['links']['self']) + self.assertIn(response.request.path_url, r['links']['self'], "Self reference for a resource collection should be its path.") + + resources = r.get('data') + self.assertIsInstance(resources, list) + self.assertGreater(len(resources), 0) + + for resource in resources: + self.assertIsInstance(resource, dict) + self.assertIn('self', resource.get('links', {}), "A resource in a collection should contain a self refrence") + self.assertEqual(f"{response.request.path_url}/{resource.get('id')}", resource['links']['self']) + + diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index 49671e6ca..7bcea0357 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -760,9 +760,6 @@ protected function obj2Resource(object $obj, array &$expandResult = [], ?array $ "type" => $this->getObjectTypeName($obj), "id" => $obj->getId(), "attributes" => $attributes, - "links" => [ - "self" => $linkSelf, - ], ]; if (sizeof($relationships) > 0) { @@ -1657,6 +1654,9 @@ protected static function getOneResource(object $apiClass, object $object, Reque $linksSelf = $request->getUri()->getPath() . ((!empty($linksQuery)) ? '?' . $linksQuery : ''); $links = ["self" => $linksSelf]; + + $resourceApiClass = $apiClass->container->get('classMapper')->get(get_class($object)); + $resourceLocation = $apiClass->routeParser->urlFor($resourceApiClass . ':getOne', ['id' => $object->getId()]); $metaData = []; if ($apiClass->permissionErrors !== null) { @@ -1669,10 +1669,8 @@ protected static function getOneResource(object $apiClass, object $object, Reque $body->write($apiClass->ret2json($ret)); return $response->withHeader("Content-Type", "application/vnd.api+json") - ->withHeader("Location", $dataResources[0]["links"]["self"]) + ->withHeader("Location", $resourceLocation) ->withStatus($statusCode); - //for location we use links value from $dataresources because if we use $linksSelf, the wrong location gets returned in - //case of a POST request } //Meta response for helper functions that do not respond with resource records diff --git a/src/inc/apiv2/common/AbstractModelAPI.php b/src/inc/apiv2/common/AbstractModelAPI.php index 5d15a0754..7f1091c1e 100644 --- a/src/inc/apiv2/common/AbstractModelAPI.php +++ b/src/inc/apiv2/common/AbstractModelAPI.php @@ -766,6 +766,10 @@ public static function getManyResources(object $apiClass, Request $request, Resp foreach ($objects as $object) { // Create object $newObject = $apiClass->obj2Resource($object, $expandResult, $request->getQueryParams()['fields'] ?? null, $request->getQueryParams()['aggregate'] ?? null); + // Resource path + $resourceApiClass = $apiClass->container->get('classMapper')->get(get_class($object)); + $resourceLocation = $apiClass->routeParser->urlFor($resourceApiClass . ':getOne', ['id' => $object->getId()]); + $newObject["links"]["self"] = $resourceLocation; $includedResources = $apiClass->processExpands($apiClass, $expands, $object, $expandResult, $includedResources, $request->getQueryParams()['fields'] ?? null, $request->getQueryParams()['aggregate'] ?? null); // Add to result output From 6fd92ac64008c4ee4df46bca8589e586545a146e Mon Sep 17 00:00:00 2001 From: andreas Date: Tue, 5 May 2026 12:59:37 +0000 Subject: [PATCH 2/2] Added test for Location header on post requests --- ci/apiv2/test_http_methods.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/ci/apiv2/test_http_methods.py b/ci/apiv2/test_http_methods.py index 70da60e0d..f6b952534 100644 --- a/ci/apiv2/test_http_methods.py +++ b/ci/apiv2/test_http_methods.py @@ -1,9 +1,12 @@ import requests +import json +import time from hashtopolis import HashtopolisConnector, HashtopolisConfig from utils import BaseTest + class HttpMethodsTest(BaseTest): @classmethod def setUpClass(cls): @@ -60,4 +63,32 @@ def test_get_many_response_should_include_self_reference_for_every_resource(self self.assertIn('self', resource.get('links', {}), "A resource in a collection should contain a self refrence") self.assertEqual(f"{response.request.path_url}/{resource.get('id')}", resource['links']['self']) + def test_post_user_should_return_getone_uri_in_location(self): + conn = HashtopolisConnector('/ui/users', self.config) + conn.authenticate() + uri = f"{conn._api_endpoint}{conn._model_uri}" + stamp = int(time.time() * 1000) + + payload = { + "data": { + "type": "user", + "attributes": { + "name": f"test-{stamp}", + "email": f"test-{stamp}@example.com", + "globalPermissionGroupId": 1, + "isValid": True, + "sessionLifetime": 3600, + }, + }, + } + + headers = dict(conn._headers) + headers["Content-Type"] = "application/json" + response = requests.post(uri, headers=headers, data=json.dumps(payload)) + + self.assertEqual(response.status_code, 201) + self.assertIsNotNone(response.headers.get('Location'), "Missing Location header in created resource") + + resource_response = requests.get(f"{conn._hashtopolis_uri}{response.headers.get('Location')}", headers=conn._headers) + self.assertEqual(resource_response.status_code, 200, "Unable to find the created resource")