diff --git a/packages/core/minos-microservice-networks/minos/networks/http/requests.py b/packages/core/minos-microservice-networks/minos/networks/http/requests.py index 3ffbc7be8..d4f65fc9e 100644 --- a/packages/core/minos-microservice-networks/minos/networks/http/requests.py +++ b/packages/core/minos-microservice-networks/minos/networks/http/requests.py @@ -119,7 +119,7 @@ def from_response(cls, response: Optional[Response]) -> HttpResponse: if response is None: return cls() - return cls(response._data) + return cls(response._data, status=response.status) class HttpResponseException(ResponseException): diff --git a/packages/core/minos-microservice-networks/tests/test_networks/test_http/test_requests.py b/packages/core/minos-microservice-networks/tests/test_networks/test_http/test_requests.py index 373ec40e9..3b5101b7b 100644 --- a/packages/core/minos-microservice-networks/tests/test_networks/test_http/test_requests.py +++ b/packages/core/minos-microservice-networks/tests/test_networks/test_http/test_requests.py @@ -112,6 +112,13 @@ async def test_from_response(self): self.assertEqual("application/json", observed.content_type) self.assertEqual({"foo": "bar"}, await observed.content()) + async def test_status_from_response(self): + response = Response({"foo": "bar"}, status=401) + observed = _HttpResponse.from_response(response) + self.assertEqual("application/json", observed.content_type) + self.assertEqual({"foo": "bar"}, await observed.content()) + self.assertEqual(401, observed.status) + def test_from_response_already(self): response = _HttpResponse() observed = _HttpResponse.from_response(response) diff --git a/packages/plugins/minos-router-graphql/README.md b/packages/plugins/minos-router-graphql/README.md index cf7148835..1d99ee021 100644 --- a/packages/plugins/minos-router-graphql/README.md +++ b/packages/plugins/minos-router-graphql/README.md @@ -47,9 +47,11 @@ class QueryService: ``` ### Execute query -Send `post` request to `http://your_ip_address:port/graphql` endpoint: -```javascript -{ SimpleQuery } +Send `post` request to `http://your_ip_address:port/service_name/graphql` endpoint: +```json +{ + "query": "{ SimpleQuery }" +} ``` You will receive: @@ -164,19 +166,11 @@ class QueryService: return Response(User(firstName="Jack", lastName="Johnson", tweets=563, id=str(id), verified=True)) ``` -If you POST `/graphql` endpoint passing the query and variables: +If you POST `{service_name}/graphql` endpoint passing the query and variables: ```json { - "query": "query ($userId: Int!) { - order_query(request: $userId) { - id - firstName - lastName - tweets - verified - } - }", + "query": "query ($userId: Int!) { GetUser(request: $userId) {id firstName lastName tweets verified}}", "variables": { "userId": 3 } @@ -188,7 +182,7 @@ Yoy will receive: ```json { "data": { - "order_query": { + "GetUser": { "id": "3", "firstName": "Jack", "lastName": "Johnson", @@ -270,7 +264,7 @@ class User(NamedTuple): class CommandService: - @enroute.graphql.command(name="GetUser", argument=GraphQLNonNull(user_input_type), output=user_type) + @enroute.graphql.command(name="CreateUser", argument=GraphQLNonNull(user_input_type), output=user_type) def test_command(self, request: Request): params = await request.content() return Response( @@ -284,21 +278,17 @@ class CommandService: ) ``` -If you POST `/graphql` endpoint passing the query and variables: +If you POST `{service_name}/graphql` endpoint passing the query and variables: ```json { - "query": "mutation ($userData: UserInputType!) { - createUser(request: $userData) { - id, firstName, lastName, tweets, verified - } - }", + "query": "mutation ($userData: UserInputType!) { CreateUser(request: $userData) {id, firstName, lastName, tweets, verified}}", "variables": { "userData": { - "firstName": "John", - "lastName":"Doe", - "tweets": 42, - "verified":true + "firstName": "John", + "lastName": "Doe", + "tweets": 42, + "verified": true } } } @@ -309,7 +299,7 @@ Yoy will receive: ```json { "data": { - "createUser": { + "CreateUser": { "id": "4kjjj43-l23k4l3-325kgaa2", "firstName": "John", "lastName": "Doe", @@ -321,6 +311,12 @@ Yoy will receive: } ``` +### Get Schema +By calling `{service_name}/graphql/schema` with `GET` method, you will receive the schema: +```text +"type Query {\n GetUser(request: Int): UserType\n}\n\ntype UserType {\n id: ID!\n firstName: String!\n lastName: String!\n tweets: Int\n verified: Boolean!\n}\n\ntype Mutation {\n CreateUser(request: UserInputType!): UserType\n}\n\ninput UserInputType {\n firstName: String!\n lastName: String!\n tweets: Int\n verified: Boolean\n}" +``` + ## Documentation The official API Reference is publicly available at the [GitHub Pages](https://minos-framework.github.io/minos-python). diff --git a/packages/plugins/minos-router-graphql/minos/plugins/graphql/decorators.py b/packages/plugins/minos-router-graphql/minos/plugins/graphql/decorators.py index 6f7016398..88161605c 100644 --- a/packages/plugins/minos-router-graphql/minos/plugins/graphql/decorators.py +++ b/packages/plugins/minos-router-graphql/minos/plugins/graphql/decorators.py @@ -25,7 +25,10 @@ def __init__(self, name: str, output, argument: Optional = None): self.output = output def __iter__(self) -> Iterable: - yield from (self.name,) + yield from ( + type(self), + self.name, + ) class GraphQlCommandEnrouteDecorator(GraphQlEnrouteDecorator): diff --git a/packages/plugins/minos-router-graphql/minos/plugins/graphql/handlers.py b/packages/plugins/minos-router-graphql/minos/plugins/graphql/handlers.py index 6d8a0cd54..cee09f688 100644 --- a/packages/plugins/minos-router-graphql/minos/plugins/graphql/handlers.py +++ b/packages/plugins/minos-router-graphql/minos/plugins/graphql/handlers.py @@ -1,9 +1,12 @@ +import logging +import traceback from typing import ( Any, ) from graphql import ( ExecutionResult, + GraphQLError, GraphQLSchema, graphql, print_schema, @@ -15,6 +18,8 @@ ResponseException, ) +logger = logging.getLogger(__name__) + class GraphQlHandler: """GraphQl Handler""" @@ -28,7 +33,9 @@ async def execute_operation(self, request: Request) -> Response: :param request: The request containing the graphql operation. :return: A response containing the graphql result. """ - result = await graphql(schema=self._schema, **(await self._build_graphql_arguments(request))) + arguments = await self._build_graphql_arguments(request) + result = await graphql(schema=self._schema, **arguments) + return self._build_response_from_graphql(result) @staticmethod @@ -47,23 +54,41 @@ async def _build_graphql_arguments(request: Request) -> dict[str, Any]: return {"source": source, "variable_values": variables} - @staticmethod - def _build_response_from_graphql(result: ExecutionResult) -> Response: - errors = result.errors - if errors is None: - errors = list() + def _build_response_from_graphql(self, result: ExecutionResult) -> Response: + content = {"data": result.data} + if result.errors is not None: + content["errors"] = [err.message for err in result.errors] + self._log_errors(result.errors) - status = 200 + status = self._get_status(result) - if len(errors): - status = 500 - for error in errors: - if isinstance(error.original_error, ResponseException): - status = error.original_error.status + return Response(content, status=status) - content = {"data": result.data, "errors": [err.message for err in errors]} + @staticmethod + def _get_status(result: ExecutionResult) -> int: + status = 200 + for error in result.errors or []: + if error.original_error is None: + current = 400 + elif isinstance(error.original_error, ResponseException): + current = error.original_error.status + else: + current = 500 + status = max(status, current) + return status - return Response(content, status=status) + @staticmethod + def _log_errors(errors: list[GraphQLError]) -> None: + for error in errors: + if error.original_error is None: + tb = repr(error) + else: + tb = "".join(traceback.format_tb(error.__traceback__)) + + if error.original_error is None or isinstance(error.original_error, ResponseException): + logger.error(f"Raised an application exception:\n {tb}") + else: + logger.exception(f"Raised a system exception:\n {tb}") async def get_schema(self, request: Request) -> Response: """Get schema diff --git a/packages/plugins/minos-router-graphql/minos/plugins/graphql/routers.py b/packages/plugins/minos-router-graphql/minos/plugins/graphql/routers.py index 322b1105a..b01e1d9be 100644 --- a/packages/plugins/minos-router-graphql/minos/plugins/graphql/routers.py +++ b/packages/plugins/minos-router-graphql/minos/plugins/graphql/routers.py @@ -34,7 +34,8 @@ def _filter_routes(self, routes: dict[EnrouteDecorator, Callable]) -> dict[Enrou } schema = GraphQLSchemaBuilder.build(routes) handler = GraphQlHandler(schema) + service_name = self._config.get_name().lower() return { - HttpEnrouteDecorator("/graphql", "POST"): handler.execute_operation, - HttpEnrouteDecorator("/graphql/schema", "GET"): handler.get_schema, + HttpEnrouteDecorator(f"/{service_name}/graphql", "POST"): handler.execute_operation, + HttpEnrouteDecorator(f"/{service_name}/graphql/schema", "GET"): handler.get_schema, } diff --git a/packages/plugins/minos-router-graphql/tests/test_config.yml b/packages/plugins/minos-router-graphql/tests/test_config.yml index 79786973a..a0fe8c184 100644 --- a/packages/plugins/minos-router-graphql/tests/test_config.yml +++ b/packages/plugins/minos-router-graphql/tests/test_config.yml @@ -1,5 +1,5 @@ service: - name: Order + name: Foo aggregate: tests.utils.Order services: - tests.services.commands.CommandService diff --git a/packages/plugins/minos-router-graphql/tests/test_graphql/test_handlers.py b/packages/plugins/minos-router-graphql/tests/test_graphql/test_handlers.py index fd5d7f68f..9397914d3 100644 --- a/packages/plugins/minos-router-graphql/tests/test_graphql/test_handlers.py +++ b/packages/plugins/minos-router-graphql/tests/test_graphql/test_handlers.py @@ -47,6 +47,10 @@ async def resolve_ticket_raises(request: Request): raise ResponseException("Some error.", status=403) +async def resolve_ticket_raises_system(request: Request): + raise ValueError() + + class TestGraphQlHandler(unittest.IsolatedAsyncioTestCase): CONFIG_FILE_PATH = BASE_PATH / "test_config.yml" _config = Config(CONFIG_FILE_PATH) @@ -66,7 +70,8 @@ async def test_execute_operation(self): result = await handler.execute_operation(request) self.assertEqual(200, result.status) - self.assertDictEqual(await result.content(), {"data": {"order_query": "ticket #4"}, "errors": []}) + expected_content = {"data": {"order_query": "ticket #4"}} + self.assertDictEqual(expected_content, await result.content()) async def test_execute_operation_raises(self): routes = { @@ -83,6 +88,21 @@ async def test_execute_operation_raises(self): self.assertEqual(403, result.status) + async def test_execute_operation_raises_system(self): + routes = { + GraphQlQueryEnrouteDecorator(name="ticket_query", output=GraphQLString): resolve_ticket_raises_system, + } + + schema = GraphQLSchemaBuilder.build(routes=routes) + + handler = GraphQlHandler(schema) + + request = InMemoryRequest(content="{ ticket_query }") + + result = await handler.execute_operation(request) + + self.assertEqual(500, result.status) + async def test_execute_wrong_operation(self): routes = { GraphQlQueryEnrouteDecorator(name="order_query", output=GraphQLString): callback_fn, @@ -99,8 +119,8 @@ async def test_execute_wrong_operation(self): content = await result.content() - self.assertEqual(500, result.status) - self.assertNotEqual(content["errors"], []) + self.assertEqual(400, result.status) + self.assertEqual(1, len(content["errors"])) async def test_schema(self): routes = { @@ -148,8 +168,8 @@ async def test_query_with_variables(self): content = await result.content() self.assertEqual(200, result.status) - self.assertDictEqual({"order_query": 3}, content["data"]) - self.assertEqual([], content["errors"]) + expected_content = {"data": {"order_query": 3}} + self.assertDictEqual(expected_content, content) async def test_simple_query(self): routes = {GraphQlQueryEnrouteDecorator(name="SimpleQuery", output=GraphQLString): resolve_simple_query} @@ -171,8 +191,8 @@ async def test_simple_query(self): content = await result.content() self.assertEqual(200, result.status) - self.assertDictEqual({"SimpleQuery": "ABCD"}, content["data"]) - self.assertEqual([], content["errors"]) + expected_content = {"data": {"SimpleQuery": "ABCD"}} + self.assertDictEqual(expected_content, content) async def test_query_with_variables_return_user(self): routes = {GraphQlQueryEnrouteDecorator(name="order_query", argument=GraphQLInt, output=user_type): resolve_user} @@ -204,11 +224,12 @@ async def test_query_with_variables_return_user(self): content = await result.content() self.assertEqual(200, result.status) - self.assertDictEqual( - {"order_query": {"id": "3", "firstName": "Jack", "lastName": "Johnson", "tweets": 563, "verified": True}}, - content["data"], - ) - self.assertEqual([], content["errors"]) + expected_content = { + "data": { + "order_query": {"id": "3", "firstName": "Jack", "lastName": "Johnson", "tweets": 563, "verified": True} + } + } + self.assertDictEqual(expected_content, content) async def test_mutation(self): routes = { @@ -240,8 +261,8 @@ async def test_mutation(self): content = await result.content() self.assertEqual(200, result.status) - self.assertDictEqual( - { + expected_content = { + "data": { "createUser": { "id": "4kjjj43-l23k4l3-325kgaa2", "firstName": "John", @@ -249,10 +270,9 @@ async def test_mutation(self): "tweets": 42, "verified": True, } - }, - content["data"], - ) - self.assertEqual([], content["errors"]) + } + } + self.assertDictEqual(expected_content, content) if __name__ == "__main__": diff --git a/packages/plugins/minos-router-graphql/tests/test_graphql/test_routers.py b/packages/plugins/minos-router-graphql/tests/test_graphql/test_routers.py index 9681278e9..169756c6a 100644 --- a/packages/plugins/minos-router-graphql/tests/test_graphql/test_routers.py +++ b/packages/plugins/minos-router-graphql/tests/test_graphql/test_routers.py @@ -23,7 +23,7 @@ def test_from_config(self): self.assertIsInstance(router, GraphQlHttpRouter) self.assertEqual( - {HttpEnrouteDecorator("/graphql", "POST"), HttpEnrouteDecorator("/graphql/schema", "GET")}, + {HttpEnrouteDecorator("/foo/graphql", "POST"), HttpEnrouteDecorator("/foo/graphql/schema", "GET")}, router.routes.keys(), )