diff --git a/src/controller.py b/src/controller.py index c876379..fdd03cf 100644 --- a/src/controller.py +++ b/src/controller.py @@ -4,7 +4,7 @@ import secrets from fastapi import FastAPI, Path -from starlette.responses import FileResponse +from starlette.responses import Response from fastapi.middleware.cors import CORSMiddleware from utils import globals as my_globals @@ -14,15 +14,32 @@ app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_credentials=True, + allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) _FILE_NAME = my_globals.DEFAULT_FLOOR_NAME + my_globals.JSON_SUFFIX +_CONTENT_DISPOSITION = f'attachment; filename="{_FILE_NAME}"' _ROOT = "/" _GENERATE = "/gen" +def _generate_floor_response(seed: str, floor_id: int) -> Response: + """ + Generates a floor and returns it as a json file without writing it to disk. + @param seed: seed for the floor + @param floor_id: id for the floor + @return: Floor as json file + """ + generator = Generator(seed=seed, output_file_name=_FILE_NAME, stage_id=floor_id) + generator.generate() + return Response( + content=generator.to_json(1), + media_type="application/json", + headers={"Content-Disposition": _CONTENT_DISPOSITION}, + ) + + @app.get(_ROOT) async def root(): """This endpoint returns a test message.""" @@ -30,47 +47,34 @@ async def root(): @app.get(_GENERATE + "/{floor_id}") -async def generate_with_id(floor_id: int = Path(ge=0)) -> FileResponse: +def generate_with_id(floor_id: int = Path(ge=0)) -> Response: """ This endpoint generates a floor with the given id - and returns it in a FileResponse as a json file. + and returns it as a json file. @param floor_id id for the floor @return: Floor in json file """ - generator = Generator( - seed=secrets.token_hex(16), output_file_name=_FILE_NAME, stage_id=floor_id - ) - generator.generate() - path = generator.save() - return FileResponse(path=path, filename=_FILE_NAME) + return _generate_floor_response(secrets.token_hex(16), floor_id) @app.get(_GENERATE + "/{floor_id}/{seed}") -async def generate_with_id_and_seed( +def generate_with_id_and_seed( floor_id: int = Path(ge=0), seed: str = Path() -) -> FileResponse: +) -> Response: """ This endpoint generates a floor with the given id - and seed and returns it in a FileResponse as a json file. + and seed and returns it as a json file. @param floor_id: id for the floor @param seed: seed for the floor @return: Floor in json file """ - generator = Generator(seed=seed, output_file_name=_FILE_NAME, stage_id=floor_id) - generator.generate() - path = generator.save() - return FileResponse(path=path, filename=_FILE_NAME) + return _generate_floor_response(seed, floor_id) @app.get(_GENERATE) -async def generate() -> FileResponse: +def generate() -> Response: """ This endpoint generates a floor with the id zero and returns it as a json file. @return: Floor as json file """ - generator = Generator( - seed=secrets.token_hex(16), output_file_name=_FILE_NAME, stage_id=0 - ) - generator.generate() - path = generator.save() - return FileResponse(path=path, filename=_FILE_NAME) + return _generate_floor_response(secrets.token_hex(16), 0) diff --git a/src/generators/generator.py b/src/generators/generator.py index a75b44a..14ee137 100644 --- a/src/generators/generator.py +++ b/src/generators/generator.py @@ -45,7 +45,7 @@ def __init__( self._output_file_name = output_file_name self._output_file_path = output_file_path self._floor: T = Floor(my_globals.FLOOR_WIDTH, my_globals.FLOOR_HEIGHT, seed) - random.seed(seed) + self._random = random.Random(seed) def to_json(self, indent: int) -> str: """ @@ -96,7 +96,7 @@ def _add_new_room( and ( self._floor.count_neighbours(new_room_tuple[0], new_room_tuple[1]) <= 1 ) - and util_functions.place_room() + and util_functions.place_room(self._random) ): room_tuple_queue.append(new_room_tuple) self._floor.add_to_floor_grid(*new_room_tuple) @@ -108,14 +108,16 @@ def generate(self, new_seed=None) -> None: Generates the rooms for the floor. """ if new_seed is not None: - random.seed(new_seed) + self._random.seed(new_seed) self._seed = new_seed self._create_floor() floor = self._floor - number_of_rooms = util_functions.calculate_room_amount(self._stage_id) + number_of_rooms = util_functions.calculate_room_amount( + self._stage_id, self._random + ) # Add start room - start_room: tuple = (random.randint(0, 8), random.randint(0, 7)) + start_room: tuple = (self._random.randint(0, 8), self._random.randint(0, 7)) floor.add_room(start_room[0], start_room[1], RoomType.START_ROOM) number_of_current_rooms = 1 room_tuple_queue: deque = deque([]) # Room coordinates @@ -222,14 +224,14 @@ def add_boss_room(self, dead_end_indices: list, start_room: tuple) -> None: self._place_boss_with_teleport_room(boss_room) boss_room_placed = True - elif len(possible_locations) >= 2 and random.randint(0, 10) < 5: + elif len(possible_locations) >= 2 and self._random.randint(0, 10) < 5: # Create a 2 * 2 boss-room boss_room_placed = self._place_big_boss_room(possible_locations, boss_room) if ( not boss_room_placed and len(possible_locations) >= 1 - and random.randint(0, 10) < 5 + and self._random.randint(0, 10) < 5 ): # Create a 1 * 2 boss-room floor.add_room_next_to(boss_room, possible_locations[0], RoomType.BOSS_ROOM) diff --git a/src/utils/util_functions.py b/src/utils/util_functions.py index 0e5a0a0..013be08 100644 --- a/src/utils/util_functions.py +++ b/src/utils/util_functions.py @@ -37,23 +37,26 @@ def get_picture_for_room_type(room_type: RoomType): return None -def calculate_room_amount(stage_id: int): +def calculate_room_amount(stage_id: int, rng: random.Random) -> int: """ Calculates the room amount. + @param stage_id: id for the floor + @param rng: random number generator to use :return: room amount """ return min( my_globals.MAX_ROOMS, - int(random.randint(0, 1) + 5 + math.floor(stage_id * 10) / 3.0), + int(rng.randint(0, 1) + 5 + math.floor(stage_id * 10) / 3.0), ) -def place_room() -> bool: +def place_room(rng: random.Random) -> bool: """ Checks if a new room should be placed. + @param rng: random number generator to use :return: True when room should be placed otherwise False """ - return random.randint(1, 50) >= 25 + return rng.randint(1, 50) >= 25 def rgb2hex(r, g, b): diff --git a/test/controller_test.py b/test/controller_test.py index c4bf63d..e0d426f 100644 --- a/test/controller_test.py +++ b/test/controller_test.py @@ -1,5 +1,9 @@ """Unit tests for the FastAPI controller.""" +import inspect +import json +import os +import tempfile import unittest from fastapi.testclient import TestClient @@ -11,6 +15,46 @@ class ControllerTest(unittest.TestCase): def setUp(self) -> None: self._client = TestClient(controller.app) + def test_generate_returns_the_floor_as_json_file(self): + response = self._client.get("/gen/2/abc") + + self.assertEqual(200, response.status_code) + self.assertEqual("application/json", response.headers["content-type"]) + self.assertEqual( + 'attachment; filename="floor.json"', + response.headers["content-disposition"], + ) + self.assertEqual("abc", json.loads(response.text)["_floor"]["_seed"]) + + def test_generate_does_not_write_files(self): + working_directory = os.getcwd() + with tempfile.TemporaryDirectory() as tmp_dir: + os.chdir(tmp_dir) + try: + self._client.get("/gen") + self._client.get("/gen/1") + self._client.get("/gen/1/abc") + self.assertEqual([], os.listdir(tmp_dir)) + finally: + os.chdir(working_directory) + + def test_generate_endpoints_run_outside_the_event_loop(self): + # Synchronous endpoints are run in a thread pool by FastAPI, + # so a long generation does not block other requests. + for endpoint in ( + controller.generate, + controller.generate_with_id, + controller.generate_with_id_and_seed, + ): + with self.subTest(endpoint=endpoint.__name__): + self.assertFalse(inspect.iscoroutinefunction(endpoint)) + + def test_cors_allows_any_origin_without_credentials(self): + response = self._client.get("/", headers={"Origin": "https://example.com"}) + + self.assertEqual("*", response.headers["access-control-allow-origin"]) + self.assertNotIn("access-control-allow-credentials", response.headers) + def test_negative_floor_id_is_rejected(self): response = self._client.get("/gen/-1") diff --git a/test/generator_test.py b/test/generator_test.py index 4de80d3..5a7dccc 100644 --- a/test/generator_test.py +++ b/test/generator_test.py @@ -3,6 +3,7 @@ """ import json import os +import random import tempfile import unittest from collections import deque @@ -152,6 +153,17 @@ def test_to_json_round_trips_through_floor_from_json(self): self.assertEqual(self._generator._floor.get_rooms(), restored.get_rooms()) + def test_generate_is_not_affected_by_the_global_random_state(self): + expected = Generator("rng-seed", "test", "", 1) + expected.generate() + generator = Generator("rng-seed", "test", "", 1) + random.seed(999) + random.random() + + generator.generate() + + self.assertEqual(expected._floor.get_rooms(), generator._floor.get_rooms()) + def test_negative_stage_id_is_rejected(self): with self.assertRaises(ValueError): Generator(seed="1", output_file_name="floor", stage_id=-1) diff --git a/test/util_test.py b/test/util_test.py index ae92e12..b347ed5 100644 --- a/test/util_test.py +++ b/test/util_test.py @@ -1,14 +1,15 @@ +import random import unittest from utils import globals, util_functions class UtilsTest(unittest.TestCase): def test_calculate_room_amount(self): - rooms = util_functions.calculate_room_amount(2) + rooms = util_functions.calculate_room_amount(2, random.Random(1)) self.assertTrue(0 < rooms <= globals.MAX_ROOMS) def test_place_room(self): - util_functions.place_room() + util_functions.place_room(random.Random(1)) if __name__ == "__main__":