diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8a0d679..09875f4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -33,7 +33,8 @@ jobs: # Run tox using the version of Python in `PATH` run: tox -e py - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + continue-on-error: true + uses: codecov/codecov-action@v4.0.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 3ec396c..a57c57a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ .nitric/ nitric.yaml nitric.*.yaml -proto/ +/proto/ +/nitric/proto/KeyValue # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/makefile b/makefile index 57b1612..329c2df 100644 --- a/makefile +++ b/makefile @@ -21,7 +21,7 @@ test: @echo Running Tox tests @tox -e py -NITRIC_VERSION := 1.1.0 +NITRIC_VERSION := 1.6.0 download-local: @rm -r ./proto/nitric diff --git a/nitric/application.py b/nitric/application.py index 1f158c8..366fed3 100644 --- a/nitric/application.py +++ b/nitric/application.py @@ -39,6 +39,7 @@ class Nitric: "websocket": {}, "keyvaluestore": {}, "oidcsecuritydefinition": {}, + "sql": {}, } @classmethod diff --git a/nitric/proto/deployments/v1/__init__.py b/nitric/proto/deployments/v1/__init__.py index e46c585..0b727bf 100644 --- a/nitric/proto/deployments/v1/__init__.py +++ b/nitric/proto/deployments/v1/__init__.py @@ -50,7 +50,7 @@ class ResourceDeploymentStatus(betterproto.Enum): IN_PROGRESS = 1 """ - The action in currently in-flight, e.g. waiting for cloud provder to + The action in currently in-flight, e.g. waiting for cloud provider to provision a resource """ @@ -299,6 +299,15 @@ class Schedule(betterproto.Message): cron: "ScheduleCron" = betterproto.message_field(11, group="cadence") +@dataclass(eq=False, repr=False) +class SqlDatabase(betterproto.Message): + image_uri: str = betterproto.string_field(1, group="migrations") + """ + The URI of a docker image to use to execute the migrations for this + database + """ + + @dataclass(eq=False, repr=False) class ScheduleEvery(betterproto.Message): rate: str = betterproto.string_field(1) @@ -328,6 +337,7 @@ class Resource(betterproto.Message): websocket: "Websocket" = betterproto.message_field(18, group="config") http: "Http" = betterproto.message_field(19, group="config") queue: "Queue" = betterproto.message_field(20, group="config") + sql_database: "SqlDatabase" = betterproto.message_field(21, group="config") @dataclass(eq=False, repr=False) diff --git a/nitric/proto/resources/v1/__init__.py b/nitric/proto/resources/v1/__init__.py index e084e7e..75babe0 100644 --- a/nitric/proto/resources/v1/__init__.py +++ b/nitric/proto/resources/v1/__init__.py @@ -37,6 +37,7 @@ class ResourceType(betterproto.Enum): Http = 11 ApiSecurityDefinition = 12 Queue = 13 + SqlDatabase = 14 class Action(betterproto.Enum): @@ -97,6 +98,7 @@ class ResourceDeclareRequest(betterproto.Message): betterproto.message_field(16, group="config") ) queue: "QueueResource" = betterproto.message_field(17, group="config") + sql_database: "SqlDatabaseResource" = betterproto.message_field(18, group="config") @dataclass(eq=False, repr=False) @@ -124,6 +126,23 @@ class SecretResource(betterproto.Message): pass +@dataclass(eq=False, repr=False) +class SqlDatabaseMigrations(betterproto.Message): + migrations_path: str = betterproto.string_field(1, group="migrations") + """ + The path to this databases SQL migrations Valid values are + file://relative/path/to/migrations as a directory or + dockerfile://path/to/migrations.dockerfile to hint at a docker image build + Paths should be relative to the root of the application (nitric.yaml file + location) + """ + + +@dataclass(eq=False, repr=False) +class SqlDatabaseResource(betterproto.Message): + migrations: "SqlDatabaseMigrations" = betterproto.message_field(1) + + @dataclass(eq=False, repr=False) class ApiOpenIdConnectionDefinition(betterproto.Message): issuer: str = betterproto.string_field(1) diff --git a/nitric/proto/sql/__init__.py b/nitric/proto/sql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nitric/proto/sql/v1/__init__.py b/nitric/proto/sql/v1/__init__.py new file mode 100644 index 0000000..29c4565 --- /dev/null +++ b/nitric/proto/sql/v1/__init__.py @@ -0,0 +1,77 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# sources: nitric/proto/sql/v1/sql.proto +# plugin: python-betterproto +# This file has been @generated + +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Dict, + Optional, +) + +import betterproto +import grpclib +from betterproto.grpc.grpclib_server import ServiceBase + + +if TYPE_CHECKING: + import grpclib.server + from betterproto.grpc.grpclib_client import MetadataLike + from grpclib.metadata import Deadline + + +@dataclass(eq=False, repr=False) +class SqlConnectionStringRequest(betterproto.Message): + database_name: str = betterproto.string_field(1) + """The name of the database to retrieve the connection string for""" + + +@dataclass(eq=False, repr=False) +class SqlConnectionStringResponse(betterproto.Message): + connection_string: str = betterproto.string_field(1) + """The connection string for the database""" + + +class SqlStub(betterproto.ServiceStub): + async def connection_string( + self, + sql_connection_string_request: "SqlConnectionStringRequest", + *, + timeout: Optional[float] = None, + deadline: Optional["Deadline"] = None, + metadata: Optional["MetadataLike"] = None + ) -> "SqlConnectionStringResponse": + return await self._unary_unary( + "/nitric.proto.sql.v1.Sql/ConnectionString", + sql_connection_string_request, + SqlConnectionStringResponse, + timeout=timeout, + deadline=deadline, + metadata=metadata, + ) + + +class SqlBase(ServiceBase): + async def connection_string( + self, sql_connection_string_request: "SqlConnectionStringRequest" + ) -> "SqlConnectionStringResponse": + raise grpclib.GRPCError(grpclib.const.Status.UNIMPLEMENTED) + + async def __rpc_connection_string( + self, + stream: "grpclib.server.Stream[SqlConnectionStringRequest, SqlConnectionStringResponse]", + ) -> None: + request = await stream.recv_message() + response = await self.connection_string(request) + await stream.send_message(response) + + def __mapping__(self) -> Dict[str, grpclib.const.Handler]: + return { + "/nitric.proto.sql.v1.Sql/ConnectionString": grpclib.const.Handler( + self.__rpc_connection_string, + grpclib.const.Cardinality.UNARY_UNARY, + SqlConnectionStringRequest, + SqlConnectionStringResponse, + ), + } diff --git a/nitric/resources/__init__.py b/nitric/resources/__init__.py index b639ff3..d70df80 100644 --- a/nitric/resources/__init__.py +++ b/nitric/resources/__init__.py @@ -26,6 +26,7 @@ from nitric.resources.topics import Topic, topic from nitric.resources.websockets import Websocket, websocket from nitric.resources.queues import Queue, queue +from nitric.resources.sql import Sql, sql __all__ = [ "api", @@ -44,6 +45,8 @@ "schedule", "secret", "Secret", + "sql", + "Sql", "topic", "Topic", "websocket", diff --git a/nitric/resources/sql.py b/nitric/resources/sql.py new file mode 100644 index 0000000..633c03e --- /dev/null +++ b/nitric/resources/sql.py @@ -0,0 +1,84 @@ +# +# Copyright (c) 2021 Nitric Technologies Pty Ltd. +# +# This file is part of Nitric Python 3 SDK. +# See https://github.com/nitrictech/python-sdk for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from __future__ import annotations + +from typing import Union + +from grpclib import GRPCError +from grpclib.client import Channel + +from nitric.exception import exception_from_grpc_error +from nitric.proto.resources.v1 import ( + SqlDatabaseResource, + SqlDatabaseMigrations, + ResourceDeclareRequest, + ResourceIdentifier, + ResourceType, +) +from nitric.resources.resource import Resource as BaseResource +from nitric.utils import new_default_channel +from nitric.application import Nitric + +from nitric.proto.sql.v1 import SqlStub, SqlConnectionStringRequest + + +class Sql(BaseResource): + """A SQL Database.""" + + _channel: Channel + _sql_stub: SqlStub + name: str + migrations: Union[str, None] + + def __init__(self, name: str, migrations: Union[str, None] = None): + """Construct a new SQL Database.""" + super().__init__(name) + + self._channel: Union[Channel, None] = new_default_channel() + self._sql_stub = SqlStub(channel=self._channel) + self.name = name + self.migrations = migrations + + async def _register(self) -> None: + try: + await self._resources_stub.declare( + resource_declare_request=ResourceDeclareRequest( + id=ResourceIdentifier(name=self.name, type=ResourceType.SqlDatabase), + sql_database=SqlDatabaseResource( + migrations=SqlDatabaseMigrations(migrations_path=self.migrations if self.migrations else "") + ), + ), + ) + except GRPCError as grpc_err: + raise exception_from_grpc_error(grpc_err) from grpc_err + + async def connection_string(self) -> str: + """Return the connection string for this SQL Database.""" + response = await self._sql_stub.connection_string(SqlConnectionStringRequest(database_name=self.name)) + + return response.connection_string + + +def sql(name: str, migrations: Union[str, None] = None) -> Sql: + """ + Create and register a sql database. + + If a sql databse has already been registered with the same name, the original reference will be reused. + """ + return Nitric._create_resource(Sql, name, migrations) diff --git a/tests/resources/test_sql.py b/tests/resources/test_sql.py new file mode 100644 index 0000000..ac4fb71 --- /dev/null +++ b/tests/resources/test_sql.py @@ -0,0 +1,61 @@ +# +# Copyright (c) 2021 Nitric Technologies Pty Ltd. +# +# This file is part of Nitric Python 3 SDK. +# See https://github.com/nitrictech/python-sdk for further info. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from unittest import IsolatedAsyncioTestCase +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from nitric.proto.resources.v1 import ( + ResourceDeclareRequest, + ResourceIdentifier, + ResourceType, + SqlDatabaseResource, +) +from nitric.resources import sql + +# pylint: disable=protected-access,missing-function-docstring,missing-class-docstring + + +class Object(object): + pass + + +class MockAsyncChannel: + def __init__(self): + self.send = AsyncMock() + self.close = Mock() + self.done = Mock() + + +class SqlTest(IsolatedAsyncioTestCase): + def test_declare_sql(self): + mock_declare = AsyncMock() + mock_response = Object() + mock_declare.return_value = mock_response + + with patch("nitric.proto.resources.v1.ResourcesStub.declare", mock_declare): + sqldb = sql("test-sql") + + # Check expected values were passed to Stub + mock_declare.assert_called_with( + resource_declare_request=ResourceDeclareRequest( + id=ResourceIdentifier(name="test-sql", type=ResourceType.SqlDatabase), + sql_database=SqlDatabaseResource(), + ) + )