diff --git a/.github/workflows/ci-testing-pull-request.yml b/.github/workflows/ci-testing-pull-request.yml
index 3d877cb091a..a8c3641b4df 100644
--- a/.github/workflows/ci-testing-pull-request.yml
+++ b/.github/workflows/ci-testing-pull-request.yml
@@ -17,7 +17,7 @@ concurrency:
jobs:
api-specs:
timeout-minutes: 10
- name: "check oas' are up to date"
+ name: "check OAS' are up to date"
runs-on: ubuntu-latest
steps:
- name: setup python environment
@@ -35,11 +35,13 @@ jobs:
run: |
uv venv .venv && source .venv/bin/activate
make openapi-specs
- ./ci/github/helpers/openapi-specs-diff.bash diff \
+ if ! ./ci/github/helpers/openapi-specs-diff.bash diff \
https://raw.githubusercontent.com/${{ github.event.pull_request.head.repo.full_name }}/refs/heads/${{ github.event.pull_request.head.ref }} \
- .
+ .; then \
+ echo "::error:: OAS are not up to date. Run 'make openapi-specs' to update them"; exit 1; \
+ fi
- api-server-backwards-compatibility:
+ api-server-oas-breaking:
needs: api-specs
timeout-minutes: 10
name: "api-server backwards compatibility"
@@ -62,11 +64,11 @@ jobs:
https://raw.githubusercontent.com/${{ github.event.pull_request.base.repo.full_name }}/refs/heads/${{ github.event.pull_request.base.ref }}/services/api-server/openapi.json \
/specs/services/api-server/openapi.json
- oas-backwards-compatibility:
+ all-oas-breaking:
needs: api-specs
continue-on-error: true
timeout-minutes: 10
- name: "oas backwards compatibility"
+ name: "OAS backwards compatibility"
runs-on: ubuntu-latest
steps:
- name: setup python environment
diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py
index d47fe0aae5e..085e8d169c4 100644
--- a/api/specs/web-server/_auth.py
+++ b/api/specs/web-server/_auth.py
@@ -9,8 +9,6 @@
from fastapi import APIRouter, status
from models_library.api_schemas_webserver.auth import (
AccountRequestInfo,
- ApiKeyCreate,
- ApiKeyGet,
UnregisterCheck,
)
from models_library.generics import Envelope
@@ -264,72 +262,6 @@ async def email_confirmation(code: str):
"""email link sent to user to confirm an action"""
-@router.get(
- "/auth/api-keys",
- operation_id="list_api_keys",
- responses={
- status.HTTP_200_OK: {
- "description": "returns the display names of API keys",
- "model": list[str],
- },
- status.HTTP_400_BAD_REQUEST: {
- "description": "key name requested is invalid",
- },
- status.HTTP_401_UNAUTHORIZED: {
- "description": "requires login to list keys",
- },
- status.HTTP_403_FORBIDDEN: {
- "description": "not enough permissions to list keys",
- },
- },
-)
-async def list_api_keys():
- """lists display names of API keys by this user"""
-
-
-@router.post(
- "/auth/api-keys",
- operation_id="create_api_key",
- responses={
- status.HTTP_200_OK: {
- "description": "Authorization granted returning API key",
- "model": ApiKeyGet,
- },
- status.HTTP_400_BAD_REQUEST: {
- "description": "key name requested is invalid",
- },
- status.HTTP_401_UNAUTHORIZED: {
- "description": "requires login to list keys",
- },
- status.HTTP_403_FORBIDDEN: {
- "description": "not enough permissions to list keys",
- },
- },
-)
-async def create_api_key(_body: ApiKeyCreate):
- """creates API keys to access public API"""
-
-
-@router.delete(
- "/auth/api-keys",
- operation_id="delete_api_key",
- status_code=status.HTTP_204_NO_CONTENT,
- responses={
- status.HTTP_204_NO_CONTENT: {
- "description": "api key successfully deleted",
- },
- status.HTTP_401_UNAUTHORIZED: {
- "description": "requires login to delete a key",
- },
- status.HTTP_403_FORBIDDEN: {
- "description": "not enough permissions to delete a key",
- },
- },
-)
-async def delete_api_key(_body: ApiKeyCreate):
- """deletes API key by name"""
-
-
@router.get(
"/auth/captcha",
operation_id="request_captcha",
diff --git a/api/specs/web-server/_auth_api_keys.py b/api/specs/web-server/_auth_api_keys.py
new file mode 100644
index 00000000000..0c6512eddda
--- /dev/null
+++ b/api/specs/web-server/_auth_api_keys.py
@@ -0,0 +1,60 @@
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, status
+from models_library.api_schemas_webserver.auth import (
+ ApiKeyCreateRequest,
+ ApiKeyCreateResponse,
+ ApiKeyGet,
+)
+from models_library.generics import Envelope
+from models_library.rest_error import EnvelopedError
+from simcore_service_webserver._meta import API_VTAG
+from simcore_service_webserver.api_keys._exceptions_handlers import _TO_HTTP_ERROR_MAP
+from simcore_service_webserver.api_keys._rest import ApiKeysPathParams
+
+router = APIRouter(
+ prefix=f"/{API_VTAG}",
+ tags=["auth"],
+ responses={
+ i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values()
+ },
+)
+
+
+@router.post(
+ "/auth/api-keys",
+ operation_id="create_api_key",
+ status_code=status.HTTP_201_CREATED,
+ response_model=Envelope[ApiKeyCreateResponse],
+)
+async def create_api_key(_body: ApiKeyCreateRequest):
+ """creates API keys to access public API"""
+
+
+@router.get(
+ "/auth/api-keys",
+ operation_id="list_api_keys",
+ response_model=Envelope[list[ApiKeyGet]],
+ status_code=status.HTTP_200_OK,
+)
+async def list_api_keys():
+ """lists API keys by this user"""
+
+
+@router.get(
+ "/auth/api-keys/{api_key_id}",
+ operation_id="get_api_key",
+ response_model=Envelope[ApiKeyGet],
+ status_code=status.HTTP_200_OK,
+)
+async def get_api_key(_path: Annotated[ApiKeysPathParams, Depends()]):
+ """returns the API Key with the given ID"""
+
+
+@router.delete(
+ "/auth/api-keys/{api_key_id}",
+ operation_id="delete_api_key",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def delete_api_key(_path: Annotated[ApiKeysPathParams, Depends()]):
+ """deletes the API key with the given ID"""
diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py
index 54b0aa4361e..6c3bc639fb4 100644
--- a/api/specs/web-server/openapi.py
+++ b/api/specs/web-server/openapi.py
@@ -20,6 +20,7 @@
#
# core ---
"_auth",
+ "_auth_api_keys",
"_groups",
"_tags",
"_tags_groups", # after _tags
diff --git a/packages/models-library/src/models_library/api_schemas_webserver/auth.py b/packages/models-library/src/models_library/api_schemas_webserver/auth.py
index c841056d40c..6fa33b3fdc4 100644
--- a/packages/models-library/src/models_library/api_schemas_webserver/auth.py
+++ b/packages/models-library/src/models_library/api_schemas_webserver/auth.py
@@ -1,10 +1,12 @@
from datetime import timedelta
-from typing import Any
+from typing import Annotated, Any
-from pydantic import BaseModel, ConfigDict, Field, SecretStr
+from models_library.basic_types import IDStr
+from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr
+from pydantic.alias_generators import to_camel
from ..emails import LowerCaseEmailStr
-from ._base import InputSchema
+from ._base import InputSchema, OutputSchema
class AccountRequestInfo(InputSchema):
@@ -51,42 +53,97 @@ class UnregisterCheck(InputSchema):
#
-class ApiKeyCreate(BaseModel):
- display_name: str = Field(..., min_length=3)
+class ApiKeyCreateRequest(InputSchema):
+ display_name: Annotated[str, Field(..., min_length=3)]
expiration: timedelta | None = Field(
None,
description="Time delta from creation time to expiration. If None, then it does not expire.",
)
model_config = ConfigDict(
+ alias_generator=AliasGenerator(
+ validation_alias=to_camel,
+ ),
+ from_attributes=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "displayName": "test-api-forever",
+ },
+ {
+ "displayName": "test-api-for-one-day",
+ "expiration": 60 * 60 * 24,
+ },
+ {
+ "displayName": "test-api-for-another-day",
+ "expiration": "24:00:00",
+ },
+ ]
+ },
+ )
+
+
+class ApiKeyCreateResponse(OutputSchema):
+ id: IDStr
+ display_name: Annotated[str, Field(..., min_length=3)]
+ expiration: timedelta | None = Field(
+ None,
+ description="Time delta from creation time to expiration. If None, then it does not expire.",
+ )
+ api_base_url: HttpUrl
+ api_key: str
+ api_secret: str
+
+ model_config = ConfigDict(
+ alias_generator=AliasGenerator(
+ serialization_alias=to_camel,
+ ),
+ from_attributes=True,
json_schema_extra={
"examples": [
{
+ "id": "42",
"display_name": "test-api-forever",
+ "api_base_url": "http://api.osparc.io/v0", # NOSONAR
+ "api_key": "key",
+ "api_secret": "secret",
},
{
+ "id": "48",
"display_name": "test-api-for-one-day",
"expiration": 60 * 60 * 24,
+ "api_base_url": "http://api.sim4life.io/v0", # NOSONAR
+ "api_key": "key",
+ "api_secret": "secret",
},
{
+ "id": "54",
"display_name": "test-api-for-another-day",
"expiration": "24:00:00",
+ "api_base_url": "http://api.osparc-master.io/v0", # NOSONAR
+ "api_key": "key",
+ "api_secret": "secret",
},
]
- }
+ },
)
-class ApiKeyGet(BaseModel):
- display_name: str = Field(..., min_length=3)
- api_key: str
- api_secret: str
+class ApiKeyGet(OutputSchema):
+ id: IDStr
+ display_name: Annotated[str, Field(..., min_length=3)]
model_config = ConfigDict(
+ alias_generator=AliasGenerator(
+ serialization_alias=to_camel,
+ ),
from_attributes=True,
json_schema_extra={
"examples": [
- {"display_name": "myapi", "api_key": "key", "api_secret": "secret"},
+ {
+ "id": "42",
+ "display_name": "myapi",
+ },
]
},
)
diff --git a/packages/models-library/src/models_library/rpc/__init__.py b/packages/models-library/src/models_library/rpc/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/models-library/src/models_library/rpc/webserver/__init__.py b/packages/models-library/src/models_library/rpc/webserver/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/models-library/src/models_library/rpc/webserver/auth/__init__.py b/packages/models-library/src/models_library/rpc/webserver/auth/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py b/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py
new file mode 100644
index 00000000000..be36327abe7
--- /dev/null
+++ b/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py
@@ -0,0 +1,35 @@
+import datetime as dt
+from typing import Annotated
+
+from models_library.basic_types import IDStr
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ApiKeyCreate(BaseModel):
+ display_name: Annotated[str, Field(..., min_length=3)]
+ expiration: dt.timedelta | None = None
+
+ model_config = ConfigDict(
+ from_attributes=True,
+ )
+
+
+class ApiKeyGet(BaseModel):
+ id: IDStr
+ display_name: Annotated[str, Field(..., min_length=3)]
+ api_key: str | None = None
+ api_secret: str | None = None
+
+ model_config = ConfigDict(
+ from_attributes=True,
+ json_schema_extra={
+ "examples": [
+ {
+ "id": "42",
+ "display_name": "test-api-forever",
+ "api_key": "key",
+ "api_secret": "secret",
+ },
+ ]
+ },
+ )
diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py
new file mode 100644
index 00000000000..e70889e3de1
--- /dev/null
+++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py
@@ -0,0 +1,66 @@
+import logging
+
+from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
+from models_library.basic_types import IDStr
+from models_library.rabbitmq_basic_types import RPCMethodName
+from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate, ApiKeyGet
+from pydantic import TypeAdapter
+from servicelib.logging_utils import log_decorator
+from servicelib.rabbitmq import RabbitMQRPCClient
+
+_logger = logging.getLogger(__name__)
+
+
+@log_decorator(_logger, level=logging.DEBUG)
+async def create_api_key(
+ rabbitmq_rpc_client: RabbitMQRPCClient,
+ *,
+ user_id: str,
+ product_name: str,
+ api_key: ApiKeyCreate,
+) -> ApiKeyGet:
+ result: ApiKeyGet = await rabbitmq_rpc_client.request(
+ WEBSERVER_RPC_NAMESPACE,
+ TypeAdapter(RPCMethodName).validate_python("create_api_key"),
+ user_id=user_id,
+ product_name=product_name,
+ api_key=api_key,
+ )
+ assert isinstance(result, ApiKeyGet)
+ return result
+
+
+@log_decorator(_logger, level=logging.DEBUG)
+async def get_api_key(
+ rabbitmq_rpc_client: RabbitMQRPCClient,
+ *,
+ user_id: str,
+ product_name: str,
+ api_key_id: IDStr,
+) -> ApiKeyGet:
+ result: ApiKeyGet = await rabbitmq_rpc_client.request(
+ WEBSERVER_RPC_NAMESPACE,
+ TypeAdapter(RPCMethodName).validate_python("get_api_key"),
+ user_id=user_id,
+ product_name=product_name,
+ api_key_id=api_key_id,
+ )
+ assert isinstance(result, ApiKeyGet)
+ return result
+
+
+async def delete_api_key(
+ rabbitmq_rpc_client: RabbitMQRPCClient,
+ *,
+ user_id: str,
+ product_name: str,
+ api_key_id: IDStr,
+) -> None:
+ result = await rabbitmq_rpc_client.request(
+ WEBSERVER_RPC_NAMESPACE,
+ TypeAdapter(RPCMethodName).validate_python("delete_api_key"),
+ user_id=user_id,
+ product_name=product_name,
+ api_key_id=api_key_id,
+ )
+ assert result is None
diff --git a/services/api-server/Makefile b/services/api-server/Makefile
index 39672c9764e..82263c83658 100644
--- a/services/api-server/Makefile
+++ b/services/api-server/Makefile
@@ -87,6 +87,7 @@ openapi-diff.md: guard-OPENAPI_JSON_BASE_URL openapi.json ## Diffs against a rem
# SEE https://schemathesis.readthedocs.io/en/stable/index.html
APP_URL:=http://$(get_my_ip).nip.io:8006
+
test-api: ## Runs schemathesis against development server (NOTE: make up-devel first)
@docker run schemathesis/schemathesis:stable run \
"$(APP_URL)/api/v0/openapi.json"
diff --git a/services/api-server/openapi.json b/services/api-server/openapi.json
index ee8d7f9479d..d70ef50e6bc 100644
--- a/services/api-server/openapi.json
+++ b/services/api-server/openapi.json
@@ -5548,6 +5548,7 @@
"urls": {
"items": {
"type": "string",
+ "minLength": 1,
"format": "uri"
},
"type": "array",
@@ -6161,6 +6162,7 @@
},
"download_link": {
"type": "string",
+ "minLength": 1,
"format": "uri",
"title": "Download Link"
}
diff --git a/services/api-server/requirements/_base.txt b/services/api-server/requirements/_base.txt
index 48d12d8b832..a49ef21c613 100644
--- a/services/api-server/requirements/_base.txt
+++ b/services/api-server/requirements/_base.txt
@@ -1,8 +1,8 @@
-aio-pika==9.4.1
+aio-pika==9.5.3
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-aiocache==0.12.2
+aiocache==0.12.3
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -11,17 +11,19 @@ aiodebug==2.3.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-aiodocker==0.21.0
+aiodocker==0.24.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-aiofiles==23.2.1
+aiofiles==24.1.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
-aiohttp==3.9.3
+aiohappyeyeballs==2.4.4
+ # via aiohttp
+aiohttp==3.11.10
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -57,25 +59,23 @@ aiopg==1.4.0
# via
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
-aiormq==6.8.0
+aiormq==6.8.1
# via aio-pika
aiosignal==1.3.1
# via aiohttp
-alembic==1.13.1
+alembic==1.14.0
# via
# -r requirements/../../../packages/postgres-database/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
annotated-types==0.7.0
# via pydantic
-anyio==4.3.0
+anyio==4.7.0
# via
# fast-depends
# faststream
# httpx
# starlette
# watchfiles
-appdirs==1.4.4
- # via pint
arrow==1.3.0
# via
# -r requirements/../../../packages/models-library/requirements/_base.in
@@ -87,16 +87,14 @@ arrow==1.3.0
asgiref==3.8.1
# via opentelemetry-instrumentation-asgi
async-timeout==4.0.3
- # via
- # aiopg
- # asyncpg
-asyncpg==0.29.0
+ # via aiopg
+asyncpg==0.30.0
# via sqlalchemy
-attrs==23.2.0
+attrs==24.2.0
# via
# aiohttp
# jsonschema
-certifi==2024.2.2
+certifi==2024.8.30
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -129,15 +127,16 @@ certifi==2024.2.2
# httpcore
# httpx
# requests
-cffi==1.16.0
+cffi==1.17.1
# via cryptography
-charset-normalizer==3.3.2
+charset-normalizer==3.4.0
# via requests
click==8.1.7
# via
+ # rich-toolkit
# typer
# uvicorn
-cryptography==42.0.5
+cryptography==44.0.0
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -168,58 +167,59 @@ cryptography==42.0.5
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_base.in
-deprecated==1.2.14
+deprecated==1.2.15
# via
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-semantic-conventions
-dnspython==2.6.1
+dnspython==2.7.0
# via email-validator
-email-validator==2.1.1
+email-validator==2.2.0
# via
# fastapi
# pydantic
-fast-depends==2.4.2
+exceptiongroup==1.2.2
+ # via aio-pika
+fast-depends==2.4.12
# via faststream
-fastapi==0.115.5
+fastapi==0.115.6
# via
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# -r requirements/_base.in
- # prometheus-fastapi-instrumentator
-fastapi-cli==0.0.5
+fastapi-cli==0.0.6
# via fastapi
-fastapi-pagination==0.12.31
+fastapi-pagination==0.12.32
# via -r requirements/_base.in
-faststream==0.5.31
+faststream==0.5.33
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
flexcache==0.3
# via pint
-flexparser==0.3.1
+flexparser==0.4
# via pint
-frozenlist==1.4.1
+frozenlist==1.5.0
# via
# aiohttp
# aiosignal
-googleapis-common-protos==1.65.0
+googleapis-common-protos==1.66.0
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-greenlet==3.0.3
+greenlet==3.1.1
# via sqlalchemy
-grpcio==1.66.0
+grpcio==1.68.1
# via opentelemetry-exporter-otlp-proto-grpc
h11==0.14.0
# via
# httpcore
# uvicorn
-httpcore==1.0.5
+httpcore==1.0.7
# via httpx
-httptools==0.6.1
+httptools==0.6.4
# via uvicorn
-httpx==0.27.0
+httpx==0.27.2
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -249,21 +249,22 @@ httpx==0.27.0
# -c requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
+ # -c requirements/./constraints.txt
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# -r requirements/_base.in
# fastapi
-idna==3.6
+idna==3.10
# via
# anyio
# email-validator
# httpx
# requests
# yarl
-importlib-metadata==8.0.0
+importlib-metadata==8.5.0
# via opentelemetry-api
-itsdangerous==2.1.2
+itsdangerous==2.2.0
# via fastapi
-jinja2==3.1.3
+jinja2==3.1.4
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -301,7 +302,7 @@ jsonschema==3.2.0
# -r requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/models-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/models-library/requirements/_base.in
-mako==1.3.2
+mako==1.3.7
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -334,17 +335,17 @@ mako==1.3.2
# alembic
markdown-it-py==3.0.0
# via rich
-markupsafe==2.1.5
+markupsafe==3.0.2
# via
# jinja2
# mako
mdurl==0.1.2
# via markdown-it-py
-multidict==6.0.5
+multidict==6.1.0
# via
# aiohttp
# yarl
-opentelemetry-api==1.27.0
+opentelemetry-api==1.28.2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -362,19 +363,19 @@ opentelemetry-api==1.27.0
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
# opentelemetry-semantic-conventions
-opentelemetry-exporter-otlp==1.27.0
+opentelemetry-exporter-otlp==1.28.2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-exporter-otlp-proto-common==1.27.0
+opentelemetry-exporter-otlp-proto-common==1.28.2
# via
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-exporter-otlp-proto-grpc==1.27.0
+opentelemetry-exporter-otlp-proto-grpc==1.28.2
# via opentelemetry-exporter-otlp
-opentelemetry-exporter-otlp-proto-http==1.27.0
+opentelemetry-exporter-otlp-proto-http==1.28.2
# via opentelemetry-exporter-otlp
-opentelemetry-instrumentation==0.48b0
+opentelemetry-instrumentation==0.49b2
# via
# opentelemetry-instrumentation-aiopg
# opentelemetry-instrumentation-asgi
@@ -385,45 +386,46 @@ opentelemetry-instrumentation==0.48b0
# opentelemetry-instrumentation-logging
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
-opentelemetry-instrumentation-aiopg==0.48b0
+opentelemetry-instrumentation-aiopg==0.49b2
# via -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-opentelemetry-instrumentation-asgi==0.48b0
+opentelemetry-instrumentation-asgi==0.49b2
# via opentelemetry-instrumentation-fastapi
-opentelemetry-instrumentation-asyncpg==0.48b0
+opentelemetry-instrumentation-asyncpg==0.49b2
# via
# -r requirements/../../../packages/postgres-database/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
-opentelemetry-instrumentation-dbapi==0.48b0
+opentelemetry-instrumentation-dbapi==0.49b2
# via opentelemetry-instrumentation-aiopg
-opentelemetry-instrumentation-fastapi==0.48b0
+opentelemetry-instrumentation-fastapi==0.49b2
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
-opentelemetry-instrumentation-httpx==0.48b0
+opentelemetry-instrumentation-httpx==0.49b2
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
-opentelemetry-instrumentation-logging==0.48b0
+opentelemetry-instrumentation-logging==0.49b2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-instrumentation-redis==0.48b0
+opentelemetry-instrumentation-redis==0.49b2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-instrumentation-requests==0.48b0
+opentelemetry-instrumentation-requests==0.49b2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-opentelemetry-proto==1.27.0
+opentelemetry-proto==1.28.2
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-sdk==1.27.0
+opentelemetry-sdk==1.28.2
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# opentelemetry-exporter-otlp-proto-grpc
# opentelemetry-exporter-otlp-proto-http
-opentelemetry-semantic-conventions==0.48b0
+opentelemetry-semantic-conventions==0.49b2
# via
+ # opentelemetry-instrumentation
# opentelemetry-instrumentation-asgi
# opentelemetry-instrumentation-asyncpg
# opentelemetry-instrumentation-dbapi
@@ -432,13 +434,13 @@ opentelemetry-semantic-conventions==0.48b0
# opentelemetry-instrumentation-redis
# opentelemetry-instrumentation-requests
# opentelemetry-sdk
-opentelemetry-util-http==0.48b0
+opentelemetry-util-http==0.49b2
# via
# opentelemetry-instrumentation-asgi
# opentelemetry-instrumentation-fastapi
# opentelemetry-instrumentation-httpx
# opentelemetry-instrumentation-requests
-orjson==3.10.0
+orjson==3.10.12
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -488,37 +490,44 @@ orjson==3.10.0
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/../../../packages/common-library/requirements/_base.in
# -r requirements/_base.in
# fastapi
-packaging==24.0
+packaging==24.2
# via
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
+ # opentelemetry-instrumentation
pamqp==3.3.0
# via aiormq
parse==1.20.2
# via -r requirements/_base.in
-pint==0.24.3
+pint==0.24.4
# via -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-prometheus-client==0.20.0
+platformdirs==4.3.6
+ # via pint
+prometheus-client==0.21.1
# via
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# prometheus-fastapi-instrumentator
-prometheus-fastapi-instrumentator==6.1.0
+prometheus-fastapi-instrumentator==7.0.0
# via -r requirements/../../../packages/service-library/requirements/_fastapi.in
-protobuf==4.25.4
+propcache==0.2.1
+ # via
+ # aiohttp
+ # yarl
+protobuf==5.29.1
# via
# googleapis-common-protos
# opentelemetry-proto
-psutil==6.0.0
+psutil==6.1.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-psycopg2-binary==2.9.9
+psycopg2-binary==2.9.10
# via
# aiopg
# sqlalchemy
pycparser==2.22
# via cffi
-pydantic==2.10.2
+pydantic==2.10.3
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -583,7 +592,7 @@ pydantic==2.10.2
# pydantic-settings
pydantic-core==2.27.1
# via pydantic
-pydantic-extra-types==2.9.0
+pydantic-extra-types==2.10.0
# via
# -r requirements/../../../packages/common-library/requirements/_base.in
# -r requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/_base.in
@@ -615,9 +624,9 @@ pydantic-settings==2.6.1
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
# fastapi
-pygments==2.17.2
+pygments==2.18.0
# via rich
-pyinstrument==4.6.2
+pyinstrument==5.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
@@ -629,9 +638,9 @@ python-dotenv==1.0.1
# via
# pydantic-settings
# uvicorn
-python-multipart==0.0.9
+python-multipart==0.0.19
# via fastapi
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -666,7 +675,7 @@ pyyaml==6.0.1
# -r requirements/_base.in
# fastapi
# uvicorn
-redis==5.0.4
+redis==5.2.0
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -704,20 +713,21 @@ repro-zipfile==0.3.1
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
requests==2.32.3
# via opentelemetry-exporter-otlp-proto-http
-rich==13.7.1
+rich==13.9.4
# via
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
+ # rich-toolkit
# typer
-setuptools==69.2.0
- # via
- # jsonschema
- # opentelemetry-instrumentation
+rich-toolkit==0.12.0
+ # via fastapi-cli
+setuptools==75.6.0
+ # via jsonschema
shellingham==1.5.4
# via typer
-six==1.16.0
+six==1.17.0
# via
# jsonschema
# python-dateutil
@@ -725,7 +735,7 @@ sniffio==1.3.1
# via
# anyio
# httpx
-sqlalchemy==1.4.52
+sqlalchemy==1.4.54
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -759,7 +769,7 @@ sqlalchemy==1.4.52
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/postgres-database/requirements/_base.in
# aiopg
# alembic
-starlette==0.41.2
+starlette==0.41.3
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -790,22 +800,23 @@ starlette==0.41.2
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
# fastapi
-tenacity==8.5.0
+ # prometheus-fastapi-instrumentator
+tenacity==9.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
# -r requirements/_base.in
-toolz==0.12.1
+toolz==1.0.0
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
-tqdm==4.66.2
+tqdm==4.67.1
# via
# -r requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/service-library/requirements/_base.in
# -r requirements/../../../packages/simcore-sdk/requirements/_base.in
-typer==0.12.3
+typer==0.15.1
# via
# -r requirements/../../../packages/service-library/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/../../../packages/settings-library/requirements/_base.in
@@ -813,13 +824,13 @@ typer==0.12.3
# -r requirements/../../../packages/simcore-sdk/requirements/../../../packages/settings-library/requirements/_base.in
# -r requirements/_base.in
# fastapi-cli
-types-python-dateutil==2.9.0.20240316
+types-python-dateutil==2.9.0.20241206
# via arrow
typing-extensions==4.12.2
# via
# aiodebug
- # aiodocker
# alembic
+ # anyio
# fastapi
# fastapi-pagination
# faststream
@@ -829,8 +840,10 @@ typing-extensions==4.12.2
# pint
# pydantic
# pydantic-core
+ # pydantic-extra-types
+ # rich-toolkit
# typer
-ujson==5.9.0
+ujson==5.10.0
# via
# -c requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
# -c requirements/../../../packages/models-library/requirements/../../../packages/common-library/requirements/../../../requirements/constraints.txt
@@ -892,25 +905,26 @@ urllib3==2.2.3
# -c requirements/../../../packages/simcore-sdk/requirements/../../../requirements/constraints.txt
# -c requirements/../../../requirements/constraints.txt
# requests
-uvicorn==0.29.0
+uvicorn==0.32.1
# via
# -r requirements/../../../packages/service-library/requirements/_fastapi.in
# fastapi
# fastapi-cli
-uvloop==0.19.0
+uvloop==0.21.0
# via uvicorn
-watchfiles==0.21.0
+watchfiles==1.0.0
# via uvicorn
-websockets==12.0
+websockets==14.1
# via uvicorn
-wrapt==1.16.0
+wrapt==1.17.0
# via
# deprecated
# opentelemetry-instrumentation
# opentelemetry-instrumentation-aiopg
# opentelemetry-instrumentation-dbapi
+ # opentelemetry-instrumentation-httpx
# opentelemetry-instrumentation-redis
-yarl==1.9.4
+yarl==1.18.3
# via
# -r requirements/../../../packages/postgres-database/requirements/_base.in
# -r requirements/../../../packages/service-library/requirements/_base.in
@@ -919,5 +933,5 @@ yarl==1.9.4
# aio-pika
# aiohttp
# aiormq
-zipp==3.20.1
+zipp==3.21.0
# via importlib-metadata
diff --git a/services/api-server/requirements/_test.txt b/services/api-server/requirements/_test.txt
index e5254193173..a9341530919 100644
--- a/services/api-server/requirements/_test.txt
+++ b/services/api-server/requirements/_test.txt
@@ -1,25 +1,29 @@
-aiohttp==3.9.3
+aiohappyeyeballs==2.4.4
+ # via
+ # -c requirements/_base.txt
+ # aiohttp
+aiohttp==3.11.10
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# aioresponses
-aioresponses==0.7.6
+aioresponses==0.7.7
# via -r requirements/_test.in
aiosignal==1.3.1
# via
# -c requirements/_base.txt
# aiohttp
-alembic==1.13.1
+alembic==1.14.0
# via
# -c requirements/_base.txt
# -r requirements/_test.in
-anyio==4.3.0
+anyio==4.7.0
# via
# -c requirements/_base.txt
# httpx
asgi-lifespan==2.1.0
# via -r requirements/_test.in
-attrs==23.2.0
+attrs==24.2.0
# via
# -c requirements/_base.txt
# aiohttp
@@ -33,28 +37,26 @@ aws-sam-translator==1.55.0
# cfn-lint
aws-xray-sdk==2.14.0
# via moto
-boto3==1.35.25
+boto3==1.35.76
# via
# aws-sam-translator
# moto
-boto3-stubs==1.35.25
- # via types-boto3
-botocore==1.35.25
+botocore==1.35.76
# via
# aws-xray-sdk
# boto3
# moto
# s3transfer
-botocore-stubs==1.35.25
- # via boto3-stubs
-certifi==2024.2.2
+botocore-stubs==1.35.76
+ # via types-boto3
+certifi==2024.8.30
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# httpcore
# httpx
# requests
-cffi==1.16.0
+cffi==1.17.1
# via
# -c requirements/_base.txt
# cryptography
@@ -62,7 +64,7 @@ cfn-lint==0.72.0
# via
# -c requirements/./constraints.txt
# moto
-charset-normalizer==3.3.2
+charset-normalizer==3.4.0
# via
# -c requirements/_base.txt
# requests
@@ -71,9 +73,9 @@ click==8.1.7
# -c requirements/_base.txt
# -r requirements/_test.in
# flask
-coverage==7.6.1
+coverage==7.6.8
# via pytest-cov
-cryptography==42.0.5
+cryptography==44.0.0
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -89,7 +91,7 @@ ecdsa==0.19.0
# moto
# python-jose
# sshpubkeys
-faker==29.0.0
+faker==33.1.0
# via -r requirements/_test.in
flask==2.1.3
# via
@@ -97,14 +99,14 @@ flask==2.1.3
# moto
flask-cors==5.0.0
# via moto
-frozenlist==1.4.1
+frozenlist==1.5.0
# via
# -c requirements/_base.txt
# aiohttp
# aiosignal
-graphql-core==3.2.4
+graphql-core==3.2.5
# via moto
-greenlet==3.0.3
+greenlet==3.1.1
# via
# -c requirements/_base.txt
# sqlalchemy
@@ -112,16 +114,17 @@ h11==0.14.0
# via
# -c requirements/_base.txt
# httpcore
-httpcore==1.0.5
+httpcore==1.0.7
# via
# -c requirements/_base.txt
# httpx
-httpx==0.27.0
+httpx==0.27.2
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
+ # -c requirements/./constraints.txt
# respx
-idna==3.6
+idna==3.10
# via
# -c requirements/_base.txt
# anyio
@@ -131,11 +134,11 @@ idna==3.6
# yarl
iniconfig==2.0.0
# via pytest
-itsdangerous==2.1.2
+itsdangerous==2.2.0
# via
# -c requirements/_base.txt
# flask
-jinja2==3.1.3
+jinja2==3.1.4
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -151,7 +154,7 @@ jsondiff==2.2.1
# via moto
jsonpatch==1.33
# via cfn-lint
-jsonpickle==3.3.0
+jsonpickle==4.0.0
# via jschema-to-python
jsonpointer==3.0.0
# via jsonpatch
@@ -167,12 +170,12 @@ jsonschema==3.2.0
# openapi-spec-validator
junit-xml==1.9
# via cfn-lint
-mako==1.3.2
+mako==1.3.7
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# alembic
-markupsafe==2.1.5
+markupsafe==3.0.2
# via
# -c requirements/_base.txt
# jinja2
@@ -182,12 +185,12 @@ moto==4.0.1
# via
# -c requirements/./constraints.txt
# -r requirements/_test.in
-multidict==6.0.5
+multidict==6.1.0
# via
# -c requirements/_base.txt
# aiohttp
# yarl
-mypy==1.12.0
+mypy==1.13.0
# via sqlalchemy
mypy-extensions==1.0.0
# via mypy
@@ -199,9 +202,10 @@ openapi-spec-validator==0.4.0
# via
# -c requirements/./constraints.txt
# moto
-packaging==24.0
+packaging==24.2
# via
# -c requirements/_base.txt
+ # aioresponses
# pytest
pbr==6.1.0
# via
@@ -209,6 +213,11 @@ pbr==6.1.0
# sarif-om
pluggy==1.5.0
# via pytest
+propcache==0.2.1
+ # via
+ # -c requirements/_base.txt
+ # aiohttp
+ # yarl
pyasn1==0.6.1
# via
# python-jose
@@ -217,17 +226,17 @@ pycparser==2.22
# via
# -c requirements/_base.txt
# cffi
-pyinstrument==4.6.2
+pyinstrument==5.0.0
# via
# -c requirements/_base.txt
# -r requirements/_test.in
-pyparsing==3.1.4
+pyparsing==3.2.0
# via moto
pyrsistent==0.20.0
# via
# -c requirements/_base.txt
# jsonschema
-pytest==8.3.3
+pytest==8.3.4
# via
# -r requirements/_test.in
# pytest-asyncio
@@ -238,7 +247,7 @@ pytest-asyncio==0.23.8
# via
# -c requirements/../../../requirements/constraints.txt
# -r requirements/_test.in
-pytest-cov==5.0.0
+pytest-cov==6.0.0
# via -r requirements/_test.in
pytest-docker==3.1.1
# via -r requirements/_test.in
@@ -256,7 +265,7 @@ python-jose==3.3.0
# via moto
pytz==2024.2
# via moto
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -279,17 +288,17 @@ rsa==4.9
# via
# -c requirements/../../../requirements/constraints.txt
# python-jose
-s3transfer==0.10.2
+s3transfer==0.10.4
# via boto3
sarif-om==1.0.4
# via cfn-lint
-setuptools==69.2.0
+setuptools==75.6.0
# via
# -c requirements/_base.txt
# jsonschema
# moto
# openapi-spec-validator
-six==1.16.0
+six==1.17.0
# via
# -c requirements/_base.txt
# ecdsa
@@ -302,7 +311,7 @@ sniffio==1.3.1
# anyio
# asgi-lifespan
# httpx
-sqlalchemy==1.4.52
+sqlalchemy==1.4.54
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
@@ -314,19 +323,21 @@ sshpubkeys==3.3.1
# via moto
types-aiofiles==24.1.0.20240626
# via -r requirements/_test.in
-types-awscrt==0.21.5
+types-awscrt==0.23.3
# via botocore-stubs
-types-boto3==1.0.2
+types-boto3==1.35.76
# via -r requirements/_test.in
-types-s3transfer==0.10.2
- # via boto3-stubs
+types-s3transfer==0.10.4
+ # via types-boto3
typing-extensions==4.12.2
# via
# -c requirements/_base.txt
# alembic
- # boto3-stubs
+ # anyio
+ # faker
# mypy
# sqlalchemy2-stubs
+ # types-boto3
urllib3==2.2.3
# via
# -c requirements/../../../requirements/constraints.txt
@@ -339,13 +350,13 @@ werkzeug==2.1.2
# via
# flask
# moto
-wrapt==1.16.0
+wrapt==1.17.0
# via
# -c requirements/_base.txt
# aws-xray-sdk
-xmltodict==0.13.0
+xmltodict==0.14.2
# via moto
-yarl==1.9.4
+yarl==1.18.3
# via
# -c requirements/_base.txt
# aiohttp
diff --git a/services/api-server/requirements/_tools.txt b/services/api-server/requirements/_tools.txt
index 795564deb10..ba68de2e0b8 100644
--- a/services/api-server/requirements/_tools.txt
+++ b/services/api-server/requirements/_tools.txt
@@ -1,8 +1,8 @@
-astroid==3.3.4
+astroid==3.3.5
# via pylint
-black==24.8.0
+black==24.10.0
# via -r requirements/../../../requirements/devenv.txt
-build==1.2.2
+build==1.2.2.post1
# via pip-tools
bump2version==1.0.1
# via -r requirements/../../../requirements/devenv.txt
@@ -16,32 +16,32 @@ click==8.1.7
# -c requirements/_test.txt
# black
# pip-tools
-dill==0.3.8
+dill==0.3.9
# via pylint
-distlib==0.3.8
+distlib==0.3.9
# via virtualenv
filelock==3.16.1
# via virtualenv
-identify==2.6.1
+identify==2.6.3
# via pre-commit
isort==5.13.2
# via
# -r requirements/../../../requirements/devenv.txt
# pylint
-jinja2==3.1.3
+jinja2==3.1.4
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# -c requirements/_test.txt
# -r requirements/_tools.in
-markupsafe==2.1.5
+markupsafe==3.0.2
# via
# -c requirements/_base.txt
# -c requirements/_test.txt
# jinja2
mccabe==0.7.0
# via pylint
-mypy==1.12.0
+mypy==1.13.0
# via
# -c requirements/_test.txt
# -r requirements/../../../requirements/devenv.txt
@@ -52,7 +52,7 @@ mypy-extensions==1.0.0
# mypy
nodeenv==1.9.1
# via pre-commit
-packaging==24.0
+packaging==24.2
# via
# -c requirements/_base.txt
# -c requirements/_test.txt
@@ -60,33 +60,34 @@ packaging==24.0
# build
pathspec==0.12.1
# via black
-pip==24.2
+pip==24.3.1
# via pip-tools
pip-tools==7.4.1
# via -r requirements/../../../requirements/devenv.txt
platformdirs==4.3.6
# via
+ # -c requirements/_base.txt
# black
# pylint
# virtualenv
-pre-commit==3.8.0
+pre-commit==4.0.1
# via -r requirements/../../../requirements/devenv.txt
-pylint==3.3.0
+pylint==3.3.2
# via -r requirements/../../../requirements/devenv.txt
-pyproject-hooks==1.1.0
+pyproject-hooks==1.2.0
# via
# build
# pip-tools
-pyyaml==6.0.1
+pyyaml==6.0.2
# via
# -c requirements/../../../requirements/constraints.txt
# -c requirements/_base.txt
# -c requirements/_test.txt
# pre-commit
# watchdog
-ruff==0.6.7
+ruff==0.8.2
# via -r requirements/../../../requirements/devenv.txt
-setuptools==69.2.0
+setuptools==75.6.0
# via
# -c requirements/_base.txt
# -c requirements/_test.txt
@@ -98,9 +99,9 @@ typing-extensions==4.12.2
# -c requirements/_base.txt
# -c requirements/_test.txt
# mypy
-virtualenv==20.26.5
+virtualenv==20.28.0
# via pre-commit
-watchdog==5.0.2
+watchdog==6.0.0
# via -r requirements/_tools.in
-wheel==0.44.0
+wheel==0.45.1
# via pip-tools
diff --git a/services/api-server/requirements/constraints.txt b/services/api-server/requirements/constraints.txt
index 6247b000156..2991a5be8b4 100644
--- a/services/api-server/requirements/constraints.txt
+++ b/services/api-server/requirements/constraints.txt
@@ -40,3 +40,6 @@ aws-sam-translator<1.56.0
# # aws-sam-translator<1.55.0 (from -c ./constraints.txt (line 32))
# # aws-sam-translator>=1.57.0 (from cfn-lint==0.72.10->-c ./constraints.txt (line 33))
cfn-lint<0.72.1
+
+# due to https://github.com/lundberg/respx/pull/278
+httpx!=0.28.0
diff --git a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py
index 65cb0934b35..a207df3aec3 100644
--- a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py
+++ b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py
@@ -4,8 +4,8 @@
from aiocache import cached # type: ignore[import-untyped]
from fastapi import FastAPI
-from models_library.api_schemas_webserver.auth import ApiKeyGet
from models_library.products import ProductName
+from models_library.rpc.webserver.auth.api_keys import ApiKeyGet
from models_library.users import UserID
from ._api_auth_rpc import get_or_create_api_key_and_secret
@@ -30,10 +30,13 @@ async def _get_or_create_for(
product_name: ProductName,
user_id: UserID,
) -> ApiKeyGet:
-
- name = create_unique_api_name_for(product_name, user_id)
+ display_name = create_unique_api_name_for(product_name, user_id)
return await get_or_create_api_key_and_secret(
- app, product_name=product_name, user_id=user_id, name=name, expiration=None
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ display_name=display_name,
+ expiration=None,
)
diff --git a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py
index c9edc8c0f1c..1a6da1f7382 100644
--- a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py
+++ b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py
@@ -2,9 +2,9 @@
from fastapi import FastAPI
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
-from models_library.api_schemas_webserver.auth import ApiKeyGet
from models_library.products import ProductName
from models_library.rabbitmq_basic_types import RPCMethodName
+from models_library.rpc.webserver.auth.api_keys import ApiKeyGet
from models_library.users import UserID
from pydantic import TypeAdapter
@@ -20,16 +20,16 @@ async def get_or_create_api_key_and_secret(
*,
product_name: ProductName,
user_id: UserID,
- name: str,
+ display_name: str,
expiration: timedelta | None = None,
) -> ApiKeyGet:
rpc_client = get_rabbitmq_rpc_client(app)
result = await rpc_client.request(
WEBSERVER_RPC_NAMESPACE,
- TypeAdapter(RPCMethodName).validate_python("get_or_create_api_keys"),
- product_name=product_name,
+ TypeAdapter(RPCMethodName).validate_python("get_or_create_api_key"),
user_id=user_id,
- name=name,
+ display_name=display_name,
expiration=expiration,
+ product_name=product_name,
)
return ApiKeyGet.model_validate(result)
diff --git a/services/director-v2/tests/conftest.py b/services/director-v2/tests/conftest.py
index 231debc371f..6337e7eea88 100644
--- a/services/director-v2/tests/conftest.py
+++ b/services/director-v2/tests/conftest.py
@@ -20,9 +20,9 @@
from asgi_lifespan import LifespanManager
from faker import Faker
from fastapi import FastAPI
-from models_library.api_schemas_webserver.auth import ApiKeyGet
from models_library.products import ProductName
from models_library.projects import Node, NodesDict
+from models_library.rpc.webserver.auth.api_keys import ApiKeyGet
from models_library.users import UserID
from pytest_mock import MockerFixture
from pytest_simcore.helpers.monkeypatch_envs import (
@@ -347,7 +347,7 @@ async def _create(
*,
product_name: ProductName,
user_id: UserID,
- name: str,
+ display_name: str,
expiration: timedelta,
):
assert app
@@ -355,7 +355,7 @@ async def _create(
assert user_id
assert expiration is None
- fake_data.display_name = name
+ fake_data.display_name = display_name
return fake_data
# mocks RPC interface
diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js
index de1d5aeddaa..b1c1d81ffa6 100644
--- a/services/static-webserver/client/source/class/osparc/data/Resources.js
+++ b/services/static-webserver/client/source/class/osparc/data/Resources.js
@@ -767,7 +767,7 @@ qx.Class.define("osparc.data.Resources", {
},
delete: {
method: "DELETE",
- url: statics.API + "/auth/api-keys"
+ url: statics.API + "/auth/api-keys/{apiKeyId}"
}
}
},
diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js
index ddc9d94f60f..6d5ae0e5258 100644
--- a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js
+++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js
@@ -77,7 +77,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
const formData = e.getData();
const params = {
data: {
- "display_name": formData["name"]
+ "displayName": formData["name"]
}
};
if (formData["expiration"]) {
@@ -90,14 +90,17 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
.then(data => {
this.__rebuildAPIKeysList();
- const key = data["api_key"];
- const secret = data["api_secret"];
- const showAPIKeyWindow = new osparc.desktop.preferences.window.ShowAPIKey(key, secret);
+ const key = data["apiKey"];
+ const secret = data["apiSecret"];
+ const baseUrl = data["apiBaseUrl"];
+ const showAPIKeyWindow = new osparc.desktop.preferences.window.ShowAPIKey(key, secret, baseUrl);
showAPIKeyWindow.center();
showAPIKeyWindow.open();
})
.catch(err => {
- osparc.FlashMessenger.getInstance().logAs(err.message, "ERROR");
+ const errorMsg = err.message || this.tr("Cannot create API Key");
+ osparc.FlashMessenger.getInstance().logAs(errorMsg, "ERROR");
+ console.error(err);
})
.finally(() => this.__requestAPIKeyBtn.setFetching(false));
}, this);
@@ -109,26 +112,24 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
osparc.data.Resources.get("apiKeys")
.then(apiKeys => {
apiKeys.forEach(apiKey => {
- const apiKeyForm = this.__createValidAPIKeyForm(apiKey);
+ const apiKeyForm = this.__createAPIKeyEntry(apiKey);
this.__apiKeysList.add(apiKeyForm);
});
})
.catch(err => console.error(err));
},
- __createValidAPIKeyForm: function(apiKeyLabel) {
+ __createAPIKeyEntry: function(apiKey) {
const grid = this.__createValidEntryLayout();
- const nameLabel = new qx.ui.basic.Label(apiKeyLabel);
+ const nameLabel = new qx.ui.basic.Label(apiKey["displayName"]);
grid.add(nameLabel, {
row: 0,
column: 0
});
const delAPIKeyBtn = new qx.ui.form.Button(null, "@FontAwesome5Solid/trash/14");
- delAPIKeyBtn.addListener("execute", e => {
- this.__deleteAPIKey(apiKeyLabel);
- }, this);
+ delAPIKeyBtn.addListener("execute", () => this.__deleteAPIKey(apiKey["id"]), this);
grid.add(delAPIKeyBtn, {
row: 0,
column: 1
@@ -137,7 +138,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
return grid;
},
- __deleteAPIKey: function(apiKeyLabel) {
+ __deleteAPIKey: function(apiKeyId) {
if (!osparc.data.Permissions.getInstance().canDo("user.apikey.delete", true)) {
return;
}
@@ -153,13 +154,17 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
win.addListener("close", () => {
if (win.getConfirmed()) {
const params = {
- data: {
- "display_name": apiKeyLabel
+ url: {
+ "apiKeyId": apiKeyId
}
};
osparc.data.Resources.fetch("apiKeys", "delete", params)
.then(() => this.__rebuildAPIKeysList())
- .catch(err => console.error(err));
+ .catch(err => {
+ const errorMsg = err.message || this.tr("Cannot delete API Key");
+ osparc.FlashMessenger.getInstance().logAs(errorMsg, "ERROR");
+ console.error(err)
+ });
}
}, this);
},
@@ -203,7 +208,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
const supportedExternalServices = osparc.utils.Utils.deepCloneObject(this.__supportedExternalServices());
tokensList.forEach(token => {
- const tokenForm = this.__createValidTokenEntry(token);
+ const tokenForm = this.__createTokenEntry(token);
this.__validTokensGB.add(tokenForm);
const idx = supportedExternalServices.findIndex(srv => srv.name === token.service);
if (idx > -1) {
@@ -244,7 +249,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", {
.catch(err => console.error(err));
},
- __createValidTokenEntry: function(token) {
+ __createTokenEntry: function(token) {
const grid = this.__createValidEntryLayout();
const service = token["service"];
diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js
index 4c63579a70f..6c7209d3ecc 100644
--- a/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js
+++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js
@@ -16,7 +16,7 @@
qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", {
extend: osparc.desktop.preferences.window.APIKeyBase,
- construct: function(key, secret) {
+ construct: function(key, secret, baseUrl) {
const caption = this.tr("API Key");
const infoText = this.tr("For your protection, store your access keys securely and do not share them. You will not be able to access the key again once this window is closed.");
this.base(arguments, caption, infoText);
@@ -25,39 +25,60 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", {
clickAwayClose: false
});
- this.__populateTokens(key, secret);
+ this.__populateTokens(key, secret, baseUrl);
},
members: {
- __populateTokens: function(key, secret) {
- const hBox1 = this.__createEntry(this.tr("Key:"), key);
+ __populateTokens: function(key, secret, baseUrl) {
+ const hBox1 = this.__createStarredEntry(this.tr("Key:"), key);
this._add(hBox1);
- const hBox2 = this.__createEntry(this.tr("Secret:"), secret);
+ const hBox2 = this.__createStarredEntry(this.tr("Secret:"), secret);
this._add(hBox2);
- const hBox3 = new qx.ui.container.Composite(new qx.ui.layout.HBox(10)).set({
+ const hBox3 = this.__createEntry(this.tr("Base url:"), baseUrl);
+ this._add(hBox3);
+
+ const buttonsLayout = new qx.ui.container.Composite(new qx.ui.layout.HBox(10)).set({
appearance: "margined-layout"
});
- const copyAPIKeyBtn = new qx.ui.form.Button(this.tr("Copy API Key"));
+ const copyAPIKeyBtn = new qx.ui.form.Button(this.tr("API Key"), "@FontAwesome5Solid/copy/12");
copyAPIKeyBtn.addListener("execute", e => {
if (osparc.utils.Utils.copyTextToClipboard(key)) {
copyAPIKeyBtn.setIcon("@FontAwesome5Solid/check/12");
}
});
- hBox3.add(copyAPIKeyBtn, {
- width: "50%"
+ buttonsLayout.add(copyAPIKeyBtn, {
+ flex: 1
});
- const copyAPISecretBtn = new qx.ui.form.Button(this.tr("Copy API Secret"));
+ const copyAPISecretBtn = new qx.ui.form.Button(this.tr("API Secret"), "@FontAwesome5Solid/copy/12");
copyAPISecretBtn.addListener("execute", e => {
if (osparc.utils.Utils.copyTextToClipboard(secret)) {
copyAPISecretBtn.setIcon("@FontAwesome5Solid/check/12");
}
});
- hBox3.add(copyAPISecretBtn, {
- width: "50%"
+ buttonsLayout.add(copyAPISecretBtn, {
+ flex: 1
});
- this._add(hBox3);
+ const copyBaseUrlBtn = new qx.ui.form.Button(this.tr("Base URL"), "@FontAwesome5Solid/copy/12");
+ copyBaseUrlBtn.addListener("execute", e => {
+ if (osparc.utils.Utils.copyTextToClipboard(baseUrl)) {
+ copyBaseUrlBtn.setIcon("@FontAwesome5Solid/check/12");
+ }
+ });
+ buttonsLayout.add(copyBaseUrlBtn, {
+ flex: 1
+ });
+ this._add(buttonsLayout);
+ },
+
+ __createStarredEntry: function(title, label) {
+ const hBox = this.__createEntry(title);
+ if (label) {
+ // partially hide the key and secret
+ hBox.getChildren()[1].setValue(label.substring(1, 8) + "****")
+ }
+ return hBox;
},
__createEntry: function(title, label) {
@@ -66,13 +87,13 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", {
});
const sTitle = new qx.ui.basic.Label(title).set({
rich: true,
- width: 40
+ width: 60
});
hBox.add(sTitle);
const sLabel = new qx.ui.basic.Label();
if (label) {
// partially hide the key and secret
- sLabel.setValue(label.substring(1, 8) + "****")
+ sLabel.setValue(label);
}
hBox.add(sLabel);
return hBox;
diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
index 54c2bfeaa16..c5061fab4c4 100644
--- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
+++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml
@@ -369,29 +369,45 @@ paths:
$ref: '#/components/schemas/Envelope_Log_'
3XX:
description: redirection to specific ui application page
+ /v0/auth/captcha:
+ get:
+ tags:
+ - auth
+ summary: Request Captcha
+ operationId: request_captcha
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema: {}
+ image/png: {}
/v0/auth/api-keys:
get:
tags:
- auth
summary: List Api Keys
- description: lists display names of API keys by this user
+ description: lists API keys by this user
operationId: list_api_keys
responses:
'200':
- description: returns the display names of API keys
+ description: Successful Response
content:
application/json:
schema:
- items:
- type: string
- type: array
- title: Response 200 List Api Keys
- '400':
- description: key name requested is invalid
- '401':
- description: requires login to list keys
- '403':
- description: not enough permissions to list keys
+ $ref: '#/components/schemas/Envelope_list_ApiKeyGet__'
+ '409':
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ '404':
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
post:
tags:
- auth
@@ -402,53 +418,92 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/ApiKeyCreate'
+ $ref: '#/components/schemas/ApiKeyCreateRequest'
required: true
+ responses:
+ '201':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_'
+ '409':
+ description: Conflict
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ '404':
+ description: Not Found
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ /v0/auth/api-keys/{api_key_id}:
+ get:
+ tags:
+ - auth
+ summary: Get Api Key
+ description: returns the API Key with the given ID
+ operationId: get_api_key
+ parameters:
+ - name: api_key_id
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 100
+ title: Api Key Id
responses:
'200':
- description: Authorization granted returning API key
+ description: Successful Response
content:
application/json:
schema:
- $ref: '#/components/schemas/ApiKeyGet'
- '400':
- description: key name requested is invalid
- '401':
- description: requires login to list keys
- '403':
- description: not enough permissions to list keys
+ $ref: '#/components/schemas/Envelope_ApiKeyGet_'
+ '409':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ description: Conflict
+ '404':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ description: Not Found
delete:
tags:
- auth
summary: Delete Api Key
- description: deletes API key by name
+ description: deletes the API key with the given ID
operationId: delete_api_key
- requestBody:
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiKeyCreate'
+ parameters:
+ - name: api_key_id
+ in: path
required: true
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 100
+ title: Api Key Id
responses:
'204':
- description: api key successfully deleted
- '401':
- description: requires login to delete a key
- '403':
- description: not enough permissions to delete a key
- /v0/auth/captcha:
- get:
- tags:
- - auth
- summary: Request Captcha
- operationId: request_captcha
- responses:
- '200':
description: Successful Response
+ '409':
content:
application/json:
- schema: {}
- image/png: {}
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ description: Conflict
+ '404':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/EnvelopedError'
+ description: Not Found
/v0/groups:
get:
tags:
@@ -6822,12 +6877,12 @@ components:
- link
- widgets
title: Announcement
- ApiKeyCreate:
+ ApiKeyCreateRequest:
properties:
- display_name:
+ displayName:
type: string
minLength: 3
- title: Display Name
+ title: Displayname
expiration:
anyOf:
- type: string
@@ -6838,25 +6893,59 @@ components:
it does not expire.
type: object
required:
- - display_name
- title: ApiKeyCreate
- ApiKeyGet:
+ - displayName
+ title: ApiKeyCreateRequest
+ ApiKeyCreateResponse:
properties:
- display_name:
+ id:
+ type: string
+ maxLength: 100
+ minLength: 1
+ title: Id
+ displayName:
type: string
minLength: 3
- title: Display Name
- api_key:
+ title: Displayname
+ expiration:
+ anyOf:
+ - type: string
+ format: duration
+ - type: 'null'
+ title: Expiration
+ description: Time delta from creation time to expiration. If None, then
+ it does not expire.
+ apiBaseUrl:
+ type: string
+ title: Apibaseurl
+ apiKey:
+ type: string
+ title: Apikey
+ apiSecret:
+ type: string
+ title: Apisecret
+ type: object
+ required:
+ - id
+ - displayName
+ - apiBaseUrl
+ - apiKey
+ - apiSecret
+ title: ApiKeyCreateResponse
+ ApiKeyGet:
+ properties:
+ id:
type: string
- title: Api Key
- api_secret:
+ maxLength: 100
+ minLength: 1
+ title: Id
+ displayName:
type: string
- title: Api Secret
+ minLength: 3
+ title: Displayname
type: object
required:
- - display_name
- - api_key
- - api_secret
+ - id
+ - displayName
title: ApiKeyGet
AppStatusCheck:
properties:
@@ -7683,6 +7772,32 @@ components:
title: Error
type: object
title: Envelope[AnyUrl]
+ Envelope_ApiKeyCreateResponse_:
+ properties:
+ data:
+ anyOf:
+ - $ref: '#/components/schemas/ApiKeyCreateResponse'
+ - type: 'null'
+ error:
+ anyOf:
+ - {}
+ - type: 'null'
+ title: Error
+ type: object
+ title: Envelope[ApiKeyCreateResponse]
+ Envelope_ApiKeyGet_:
+ properties:
+ data:
+ anyOf:
+ - $ref: '#/components/schemas/ApiKeyGet'
+ - type: 'null'
+ error:
+ anyOf:
+ - {}
+ - type: 'null'
+ title: Error
+ type: object
+ title: Envelope[ApiKeyGet]
Envelope_AppStatusCheck_:
properties:
data:
@@ -8657,6 +8772,22 @@ components:
title: Error
type: object
title: Envelope[list[Announcement]]
+ Envelope_list_ApiKeyGet__:
+ properties:
+ data:
+ anyOf:
+ - items:
+ $ref: '#/components/schemas/ApiKeyGet'
+ type: array
+ - type: 'null'
+ title: Data
+ error:
+ anyOf:
+ - {}
+ - type: 'null'
+ title: Error
+ type: object
+ title: Envelope[list[ApiKeyGet]]
Envelope_list_DatasetMetaData__:
properties:
data:
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_api.py b/services/web/server/src/simcore_service_webserver/api_keys/_api.py
deleted file mode 100644
index 6cdc15e2f24..00000000000
--- a/services/web/server/src/simcore_service_webserver/api_keys/_api.py
+++ /dev/null
@@ -1,117 +0,0 @@
-import re
-import string
-from datetime import timedelta
-from typing import Final
-
-from aiohttp import web
-from models_library.api_schemas_webserver.auth import ApiKeyCreate, ApiKeyGet
-from models_library.products import ProductName
-from models_library.users import UserID
-from servicelib.utils_secrets import generate_token_secret_key
-
-from ._db import ApiKeyRepo
-
-_PUNCTUATION_REGEX = re.compile(
- pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]"
-)
-
-_KEY_LEN: Final = 10
-_SECRET_LEN: Final = 20
-
-
-async def list_api_keys(
- app: web.Application,
- *,
- user_id: UserID,
- product_name: ProductName,
-) -> list[str]:
- repo = ApiKeyRepo.create_from_app(app)
- names: list[str] = await repo.list_names(user_id=user_id, product_name=product_name)
- return names
-
-
-def _generate_api_key_and_secret(name: str):
- prefix = _PUNCTUATION_REGEX.sub("_", name[:5])
- api_key = f"{prefix}_{generate_token_secret_key(_KEY_LEN)}"
- api_secret = generate_token_secret_key(_SECRET_LEN)
- return api_key, api_secret
-
-
-async def create_api_key(
- app: web.Application,
- *,
- new: ApiKeyCreate,
- user_id: UserID,
- product_name: ProductName,
-) -> ApiKeyGet:
- # generate key and secret
- api_key, api_secret = _generate_api_key_and_secret(new.display_name)
-
- # raises if name exists already!
- repo = ApiKeyRepo.create_from_app(app)
- await repo.create(
- user_id=user_id,
- product_name=product_name,
- display_name=new.display_name,
- expiration=new.expiration,
- api_key=api_key,
- api_secret=api_secret,
- )
-
- return ApiKeyGet(
- display_name=new.display_name,
- api_key=api_key,
- api_secret=api_secret,
- )
-
-
-async def get_api_key(
- app: web.Application, *, name: str, user_id: UserID, product_name: ProductName
-) -> ApiKeyGet | None:
- repo = ApiKeyRepo.create_from_app(app)
- row = await repo.get(display_name=name, user_id=user_id, product_name=product_name)
- return ApiKeyGet.model_validate(row) if row else None
-
-
-async def get_or_create_api_key(
- app: web.Application,
- *,
- name: str,
- user_id: UserID,
- product_name: ProductName,
- expiration: timedelta | None = None,
-) -> ApiKeyGet:
-
- api_key, api_secret = _generate_api_key_and_secret(name)
-
- repo = ApiKeyRepo.create_from_app(app)
- row = await repo.get_or_create(
- user_id=user_id,
- product_name=product_name,
- display_name=name,
- expiration=expiration,
- api_key=api_key,
- api_secret=api_secret,
- )
- return ApiKeyGet.model_construct(
- display_name=row.display_name, api_key=row.api_key, api_secret=row.api_secret
- )
-
-
-async def delete_api_key(
- app: web.Application,
- *,
- name: str,
- user_id: UserID,
- product_name: ProductName,
-) -> None:
- repo = ApiKeyRepo.create_from_app(app)
- await repo.delete_by_name(
- display_name=name, user_id=user_id, product_name=product_name
- )
-
-
-async def prune_expired_api_keys(app: web.Application) -> list[str]:
- repo = ApiKeyRepo.create_from_app(app)
- names: list[str] = await repo.prune_expired()
- return names
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_db.py b/services/web/server/src/simcore_service_webserver/api_keys/_db.py
deleted file mode 100644
index ec08ce5dd67..00000000000
--- a/services/web/server/src/simcore_service_webserver/api_keys/_db.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import logging
-from dataclasses import dataclass
-from datetime import timedelta
-
-import sqlalchemy as sa
-from aiohttp import web
-from aiopg.sa.engine import Engine
-from aiopg.sa.result import ResultProxy, RowProxy
-from models_library.api_schemas_api_server.api_keys import ApiKeyInDB
-from models_library.basic_types import IdInt
-from models_library.products import ProductName
-from models_library.users import UserID
-from simcore_postgres_database.models.api_keys import api_keys
-from sqlalchemy.dialects.postgresql import insert as pg_insert
-
-from ..db.plugin import get_database_engine
-
-_logger = logging.getLogger(__name__)
-
-
-@dataclass
-class ApiKeyRepo:
- engine: Engine
-
- @classmethod
- def create_from_app(cls, app: web.Application):
- return cls(engine=get_database_engine(app))
-
- async def list_names(
- self, *, user_id: UserID, product_name: ProductName
- ) -> list[str]:
- async with self.engine.acquire() as conn:
- stmt = sa.select(api_keys.c.display_name).where(
- (api_keys.c.user_id == user_id)
- & (api_keys.c.product_name == product_name)
- )
-
- result: ResultProxy = await conn.execute(stmt)
- rows = await result.fetchall() or []
- return [r.display_name for r in rows]
-
- async def create(
- self,
- *,
- user_id: UserID,
- product_name: ProductName,
- display_name: str,
- expiration: timedelta | None,
- api_key: str,
- api_secret: str,
- ) -> list[IdInt]:
- async with self.engine.acquire() as conn:
- stmt = (
- api_keys.insert()
- .values(
- display_name=display_name,
- user_id=user_id,
- product_name=product_name,
- api_key=api_key,
- api_secret=api_secret,
- expires_at=(sa.func.now() + expiration) if expiration else None,
- )
- .returning(api_keys.c.id)
- )
-
- result: ResultProxy = await conn.execute(stmt)
- rows = await result.fetchall() or []
- return [r.id for r in rows]
-
- async def get(
- self, *, display_name: str, user_id: UserID, product_name: ProductName
- ) -> ApiKeyInDB | None:
- async with self.engine.acquire() as conn:
- stmt = sa.select(api_keys).where(
- (api_keys.c.user_id == user_id)
- & (api_keys.c.display_name == display_name)
- & (api_keys.c.product_name == product_name)
- )
-
- result: ResultProxy = await conn.execute(stmt)
- row: RowProxy | None = await result.fetchone()
- return ApiKeyInDB.model_validate(row) if row else None
-
- async def get_or_create(
- self,
- *,
- user_id: UserID,
- product_name: ProductName,
- display_name: str,
- expiration: timedelta | None,
- api_key: str,
- api_secret: str,
- ) -> ApiKeyInDB:
- async with self.engine.acquire() as conn:
- # Implemented as "create or get"
- insert_stmt = (
- pg_insert(api_keys)
- .values(
- display_name=display_name,
- user_id=user_id,
- product_name=product_name,
- api_key=api_key,
- api_secret=api_secret,
- expires_at=(sa.func.now() + expiration) if expiration else None,
- )
- .on_conflict_do_update(
- index_elements=["user_id", "display_name"],
- set_={
- "product_name": product_name
- }, # dummy enable returning since on_conflict_do_nothing returns None
- # NOTE: use this entry for reference counting in https://github.com/ITISFoundation/osparc-simcore/issues/5875
- )
- .returning(api_keys)
- )
-
- result = await conn.execute(insert_stmt)
- row = await result.fetchone()
- assert row # nosec
- return ApiKeyInDB.model_validate(row)
-
- async def delete_by_name(
- self, *, display_name: str, user_id: UserID, product_name: ProductName
- ) -> None:
- async with self.engine.acquire() as conn:
- stmt = api_keys.delete().where(
- (api_keys.c.user_id == user_id)
- & (api_keys.c.display_name == display_name)
- & (api_keys.c.product_name == product_name)
- )
- await conn.execute(stmt)
-
- async def delete_by_key(
- self, *, api_key: str, user_id: UserID, product_name: ProductName
- ) -> None:
- async with self.engine.acquire() as conn:
- stmt = api_keys.delete().where(
- (api_keys.c.user_id == user_id)
- & (api_keys.c.api_key == api_key)
- & (api_keys.c.product_name == product_name)
- )
- await conn.execute(stmt)
-
- async def prune_expired(self) -> list[str]:
- async with self.engine.acquire() as conn:
- stmt = (
- api_keys.delete()
- .where(
- (api_keys.c.expires_at.is_not(None))
- & (api_keys.c.expires_at < sa.func.now())
- )
- .returning(api_keys.c.display_name)
- )
- result: ResultProxy = await conn.execute(stmt)
- rows = await result.fetchall() or []
- return [r.display_name for r in rows]
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py
new file mode 100644
index 00000000000..4cb6c84f824
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py
@@ -0,0 +1,26 @@
+from servicelib.aiohttp import status
+
+from ..exception_handling import (
+ ExceptionToHttpErrorMap,
+ HttpErrorInfo,
+ exception_handling_decorator,
+ to_exceptions_handlers_map,
+)
+from .errors import ApiKeyDuplicatedDisplayNameError, ApiKeyNotFoundError
+
+_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = {
+ ApiKeyDuplicatedDisplayNameError: HttpErrorInfo(
+ status.HTTP_409_CONFLICT,
+ "API key display name duplicated",
+ ),
+ ApiKeyNotFoundError: HttpErrorInfo(
+ status.HTTP_404_NOT_FOUND,
+ "API key was not found",
+ ),
+}
+
+
+handle_plugin_requests_exceptions = exception_handling_decorator(
+ to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP)
+)
+# this is one decorator with a single exception handler
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py
deleted file mode 100644
index 4a7a84fe742..00000000000
--- a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py
+++ /dev/null
@@ -1,81 +0,0 @@
-import logging
-
-from aiohttp import web
-from aiohttp.web import RouteTableDef
-from models_library.api_schemas_webserver.auth import ApiKeyCreate
-from servicelib.aiohttp import status
-from servicelib.aiohttp.requests_validation import parse_request_body_as
-from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON
-from simcore_postgres_database.errors import DatabaseError
-
-from .._meta import API_VTAG
-from ..login.decorators import login_required
-from ..models import RequestContext
-from ..security.decorators import permission_required
-from ..utils_aiohttp import envelope_json_response
-from . import _api
-
-_logger = logging.getLogger(__name__)
-
-
-routes = RouteTableDef()
-
-
-@routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys")
-@login_required
-@permission_required("user.apikey.*")
-async def list_api_keys(request: web.Request):
- req_ctx = RequestContext.model_validate(request)
- api_keys_names = await _api.list_api_keys(
- request.app,
- user_id=req_ctx.user_id,
- product_name=req_ctx.product_name,
- )
- return envelope_json_response(api_keys_names)
-
-
-@routes.post(f"/{API_VTAG}/auth/api-keys", name="create_api_key")
-@login_required
-@permission_required("user.apikey.*")
-async def create_api_key(request: web.Request):
- req_ctx = RequestContext.model_validate(request)
- new = await parse_request_body_as(ApiKeyCreate, request)
- try:
- data = await _api.create_api_key(
- request.app,
- new=new,
- user_id=req_ctx.user_id,
- product_name=req_ctx.product_name,
- )
- except DatabaseError as err:
- raise web.HTTPBadRequest(
- reason="Invalid API key name: already exists",
- content_type=MIMETYPE_APPLICATION_JSON,
- ) from err
-
- return envelope_json_response(data)
-
-
-@routes.delete(f"/{API_VTAG}/auth/api-keys", name="delete_api_key")
-@login_required
-@permission_required("user.apikey.*")
-async def delete_api_key(request: web.Request):
- req_ctx = RequestContext.model_validate(request)
-
- # NOTE: SEE https://github.com/ITISFoundation/osparc-simcore/issues/4920
- body = await request.json()
- name = body.get("display_name")
-
- try:
- await _api.delete_api_key(
- request.app,
- name=name,
- user_id=req_ctx.user_id,
- product_name=req_ctx.product_name,
- )
- except DatabaseError as err:
- _logger.warning(
- "Failed to delete API key %s. Ignoring error", name, exc_info=err
- )
-
- return web.json_response(status=status.HTTP_204_NO_CONTENT)
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_models.py b/services/web/server/src/simcore_service_webserver/api_keys/_models.py
new file mode 100644
index 00000000000..ee0ecec85c2
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/api_keys/_models.py
@@ -0,0 +1,11 @@
+import datetime as dt
+from dataclasses import dataclass
+
+
+@dataclass
+class ApiKey:
+ id: str
+ display_name: str
+ expiration: dt.timedelta | None = None
+ api_key: str | None = None
+ api_secret: str | None = None
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_repository.py b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py
new file mode 100644
index 00000000000..1f4a8dbdc79
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py
@@ -0,0 +1,231 @@
+import logging
+from datetime import timedelta
+
+import sqlalchemy as sa
+from aiohttp import web
+from asyncpg.exceptions import UniqueViolationError
+from models_library.products import ProductName
+from models_library.users import UserID
+from simcore_postgres_database.models.api_keys import api_keys
+from simcore_postgres_database.utils_repos import transaction_context
+from simcore_service_webserver.api_keys._models import ApiKey
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+from ..db.plugin import get_asyncpg_engine
+from .errors import ApiKeyDuplicatedDisplayNameError
+
+_logger = logging.getLogger(__name__)
+
+
+async def create_api_key(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+ display_name: str,
+ expiration: timedelta | None,
+ api_key: str,
+ api_secret: str,
+) -> ApiKey:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ try:
+ stmt = (
+ api_keys.insert()
+ .values(
+ display_name=display_name,
+ user_id=user_id,
+ product_name=product_name,
+ api_key=api_key,
+ api_secret=api_secret,
+ expires_at=(sa.func.now() + expiration) if expiration else None,
+ )
+ .returning(api_keys.c.id)
+ )
+
+ result = await conn.stream(stmt)
+ row = await result.first()
+
+ return ApiKey(
+ id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919
+ display_name=display_name,
+ expiration=expiration,
+ api_key=api_key,
+ api_secret=api_secret,
+ )
+ except UniqueViolationError as exc:
+ raise ApiKeyDuplicatedDisplayNameError(display_name=display_name) from exc
+
+
+async def get_or_create_api_key(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+ display_name: str,
+ expiration: timedelta | None,
+ api_key: str,
+ api_secret: str,
+) -> ApiKey:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ # Implemented as "create or get"
+ insert_stmt = (
+ pg_insert(api_keys)
+ .values(
+ display_name=display_name,
+ user_id=user_id,
+ product_name=product_name,
+ api_key=api_key,
+ api_secret=api_secret,
+ expires_at=(sa.func.now() + expiration) if expiration else None,
+ )
+ .on_conflict_do_update(
+ index_elements=["user_id", "display_name"],
+ set_={
+ "product_name": product_name
+ }, # dummy enable returning since on_conflict_do_nothing returns None
+ # NOTE: use this entry for reference counting in https://github.com/ITISFoundation/osparc-simcore/issues/5875
+ )
+ .returning(api_keys)
+ )
+
+ result = await conn.stream(insert_stmt)
+ row = await result.first()
+ assert row # nosec
+
+ return ApiKey(
+ id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919
+ display_name=row.display_name,
+ expiration=row.expires_at,
+ api_key=row.api_key,
+ api_secret=row.api_secret,
+ )
+
+
+async def list_api_keys(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+) -> list[ApiKey]:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ stmt = sa.select(api_keys.c.id, api_keys.c.display_name).where(
+ (api_keys.c.user_id == user_id) & (api_keys.c.product_name == product_name)
+ )
+
+ result = await conn.stream(stmt)
+ rows = [row async for row in result]
+
+ return [
+ ApiKey(
+ id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919
+ display_name=row.display_name,
+ )
+ for row in rows
+ ]
+
+
+async def get_api_key(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ api_key_id: str,
+ user_id: UserID,
+ product_name: ProductName,
+) -> ApiKey | None:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ stmt = sa.select(api_keys).where(
+ (
+ api_keys.c.id == int(api_key_id)
+ ) # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919
+ & (api_keys.c.user_id == user_id)
+ & (api_keys.c.product_name == product_name)
+ )
+
+ result = await conn.stream(stmt)
+ row = await result.first()
+
+ return (
+ ApiKey(
+ id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919
+ display_name=row.display_name,
+ expiration=row.expires_at,
+ api_key=row.api_key,
+ api_secret=row.api_secret,
+ )
+ if row
+ else None
+ )
+
+
+async def delete_api_key(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+ api_key_id: str,
+) -> None:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ stmt = api_keys.delete().where(
+ (
+ api_keys.c.id == int(api_key_id)
+ ) # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919
+ & (api_keys.c.user_id == user_id)
+ & (api_keys.c.product_name == product_name)
+ )
+ await conn.execute(stmt)
+
+
+async def delete_by_name(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ display_name: str,
+ user_id: UserID,
+ product_name: ProductName,
+) -> None:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ stmt = api_keys.delete().where(
+ (api_keys.c.user_id == user_id)
+ & (api_keys.c.display_name == display_name)
+ & (api_keys.c.product_name == product_name)
+ )
+ await conn.execute(stmt)
+
+
+async def delete_by_key(
+ app: web.Application,
+ connection: AsyncConnection | None = None,
+ *,
+ api_key: str,
+ user_id: UserID,
+ product_name: ProductName,
+) -> None:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ stmt = api_keys.delete().where(
+ (api_keys.c.user_id == user_id)
+ & (api_keys.c.api_key == api_key)
+ & (api_keys.c.product_name == product_name)
+ )
+ await conn.execute(stmt)
+
+
+async def prune_expired(
+ app: web.Application, connection: AsyncConnection | None = None
+) -> list[str]:
+ async with transaction_context(get_asyncpg_engine(app), connection) as conn:
+ stmt = (
+ api_keys.delete()
+ .where(
+ (api_keys.c.expires_at.is_not(None))
+ & (api_keys.c.expires_at < sa.func.now())
+ )
+ .returning(api_keys.c.display_name)
+ )
+ result = await conn.stream(stmt)
+ rows = [row async for row in result]
+ return [r.display_name for r in rows]
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_rest.py b/services/web/server/src/simcore_service_webserver/api_keys/_rest.py
new file mode 100644
index 00000000000..c3b81c63cd3
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/api_keys/_rest.py
@@ -0,0 +1,112 @@
+import logging
+from dataclasses import asdict
+
+from aiohttp import web
+from aiohttp.web import RouteTableDef
+from models_library.api_schemas_webserver.auth import (
+ ApiKeyCreateRequest,
+ ApiKeyCreateResponse,
+ ApiKeyGet,
+)
+from models_library.basic_types import IDStr
+from models_library.rest_base import StrictRequestParameters
+from pydantic import TypeAdapter
+from servicelib.aiohttp import status
+from servicelib.aiohttp.requests_validation import (
+ parse_request_body_as,
+ parse_request_path_parameters_as,
+)
+
+from .._meta import API_VTAG
+from ..login.decorators import login_required
+from ..models import RequestContext
+from ..security.decorators import permission_required
+from ..utils_aiohttp import envelope_json_response
+from . import _service
+from ._exceptions_handlers import handle_plugin_requests_exceptions
+from ._models import ApiKey
+
+_logger = logging.getLogger(__name__)
+
+
+routes = RouteTableDef()
+
+
+class ApiKeysPathParams(StrictRequestParameters):
+ api_key_id: IDStr
+
+
+@routes.post(f"/{API_VTAG}/auth/api-keys", name="create_api_key")
+@login_required
+@permission_required("user.apikey.*")
+@handle_plugin_requests_exceptions
+async def create_api_key(request: web.Request):
+ req_ctx = RequestContext.model_validate(request)
+ new_api_key = await parse_request_body_as(ApiKeyCreateRequest, request)
+
+ created_api_key: ApiKey = await _service.create_api_key(
+ request.app,
+ display_name=new_api_key.display_name,
+ expiration=new_api_key.expiration,
+ user_id=req_ctx.user_id,
+ product_name=req_ctx.product_name,
+ )
+
+ api_key = ApiKeyCreateResponse.model_validate(
+ {
+ **asdict(created_api_key),
+ "api_base_url": "http://localhost:8000",
+ } # TODO: https://github.com/ITISFoundation/osparc-simcore/issues/6340 # @pcrespov
+ )
+
+ return envelope_json_response(api_key)
+
+
+@routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys")
+@login_required
+@permission_required("user.apikey.*")
+@handle_plugin_requests_exceptions
+async def list_api_keys(request: web.Request):
+ req_ctx = RequestContext.model_validate(request)
+ api_keys = await _service.list_api_keys(
+ request.app,
+ user_id=req_ctx.user_id,
+ product_name=req_ctx.product_name,
+ )
+ return envelope_json_response(
+ TypeAdapter(list[ApiKeyGet]).validate_python(api_keys)
+ )
+
+
+@routes.get(f"/{API_VTAG}/auth/api-keys/{{api_key_id}}", name="get_api_key")
+@login_required
+@permission_required("user.apikey.*")
+@handle_plugin_requests_exceptions
+async def get_api_key(request: web.Request):
+ req_ctx = RequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(ApiKeysPathParams, request)
+ api_key: ApiKey = await _service.get_api_key(
+ request.app,
+ api_key_id=path_params.api_key_id,
+ user_id=req_ctx.user_id,
+ product_name=req_ctx.product_name,
+ )
+ return envelope_json_response(ApiKeyGet.model_validate(api_key))
+
+
+@routes.delete(f"/{API_VTAG}/auth/api-keys/{{api_key_id}}", name="delete_api_key")
+@login_required
+@permission_required("user.apikey.*")
+@handle_plugin_requests_exceptions
+async def delete_api_key(request: web.Request):
+ req_ctx = RequestContext.model_validate(request)
+ path_params = parse_request_path_parameters_as(ApiKeysPathParams, request)
+
+ await _service.delete_api_key(
+ request.app,
+ api_key_id=path_params.api_key_id,
+ user_id=req_ctx.user_id,
+ product_name=req_ctx.product_name,
+ )
+
+ return web.json_response(status=status.HTTP_204_NO_CONTENT)
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py b/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py
index d54b24c1667..3dd04600cfa 100644
--- a/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py
+++ b/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py
@@ -2,71 +2,88 @@
from aiohttp import web
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
-from models_library.api_schemas_webserver.auth import ApiKeyCreate, ApiKeyGet
+from models_library.api_schemas_webserver.auth import ApiKeyCreateRequest
from models_library.products import ProductName
+from models_library.rpc.webserver.auth.api_keys import ApiKeyGet
from models_library.users import UserID
from servicelib.rabbitmq import RPCRouter
from ..rabbitmq import get_rabbitmq_rpc_server
-from . import _api
+from . import _service
+from ._models import ApiKey
+from .errors import ApiKeyNotFoundError
router = RPCRouter()
@router.expose()
-async def create_api_keys(
+async def create_api_key(
app: web.Application,
*,
- product_name: ProductName,
user_id: UserID,
- new: ApiKeyCreate,
+ product_name: ProductName,
+ api_key: ApiKeyCreateRequest,
) -> ApiKeyGet:
- return await _api.create_api_key(
- app, new=new, user_id=user_id, product_name=product_name
+ created_api_key: ApiKey = await _service.create_api_key(
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ display_name=api_key.display_name,
+ expiration=api_key.expiration,
)
+ return ApiKeyGet.model_validate(created_api_key)
-@router.expose()
-async def delete_api_keys(
+
+@router.expose(reraise_if_error_type=(ApiKeyNotFoundError,))
+async def get_api_key(
app: web.Application,
*,
- product_name: ProductName,
user_id: UserID,
- name: str,
-) -> None:
- await _api.delete_api_key(
- app, name=name, user_id=user_id, product_name=product_name
+ product_name: ProductName,
+ api_key_id: str,
+) -> ApiKeyGet:
+ api_key: ApiKey = await _service.get_api_key(
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ api_key_id=api_key_id,
)
+ return ApiKeyGet.model_validate(api_key)
@router.expose()
-async def api_key_get(
+async def get_or_create_api_key(
app: web.Application,
*,
- product_name: ProductName,
user_id: UserID,
- name: str,
-) -> ApiKeyGet | None:
- return await _api.get_api_key(
- app, name=name, user_id=user_id, product_name=product_name
+ product_name: ProductName,
+ display_name: str,
+ expiration: timedelta | None = None,
+) -> ApiKeyGet:
+ api_key: ApiKey = await _service.get_or_create_api_key(
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ display_name=display_name,
+ expiration=expiration,
)
+ return ApiKeyGet.model_validate(api_key)
@router.expose()
-async def get_or_create_api_keys(
+async def delete_api_key(
app: web.Application,
*,
- product_name: ProductName,
user_id: UserID,
- name: str,
- expiration: timedelta | None = None,
-) -> ApiKeyGet:
- return await _api.get_or_create_api_key(
+ product_name: ProductName,
+ api_key_id: str,
+) -> None:
+ await _service.delete_api_key(
app,
- name=name,
user_id=user_id,
product_name=product_name,
- expiration=expiration,
+ api_key_id=api_key_id,
)
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_service.py b/services/web/server/src/simcore_service_webserver/api_keys/_service.py
new file mode 100644
index 00000000000..4d7cdcb43dc
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/api_keys/_service.py
@@ -0,0 +1,123 @@
+import datetime as dt
+import re
+import string
+from typing import Final
+
+from aiohttp import web
+from models_library.products import ProductName
+from models_library.users import UserID
+from servicelib.utils_secrets import generate_token_secret_key
+
+from . import _repository
+from ._models import ApiKey
+from .errors import ApiKeyNotFoundError
+
+_PUNCTUATION_REGEX = re.compile(
+ pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]"
+)
+
+_KEY_LEN: Final = 10
+_SECRET_LEN: Final = 20
+
+
+def _generate_api_key_and_secret(name: str):
+ prefix = _PUNCTUATION_REGEX.sub("_", name[:5])
+ api_key = f"{prefix}_{generate_token_secret_key(_KEY_LEN)}"
+ api_secret = generate_token_secret_key(_SECRET_LEN)
+ return api_key, api_secret
+
+
+async def create_api_key(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+ display_name=str,
+ expiration=dt.timedelta,
+) -> ApiKey:
+ api_key, api_secret = _generate_api_key_and_secret(display_name)
+
+ return await _repository.create_api_key(
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ display_name=display_name,
+ expiration=expiration,
+ api_key=api_key,
+ api_secret=api_secret,
+ )
+
+
+async def list_api_keys(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+) -> list[ApiKey]:
+ api_keys: list[ApiKey] = await _repository.list_api_keys(
+ app, user_id=user_id, product_name=product_name
+ )
+ return api_keys
+
+
+async def get_api_key(
+ app: web.Application,
+ *,
+ api_key_id: str,
+ user_id: UserID,
+ product_name: ProductName,
+) -> ApiKey:
+ api_key: ApiKey | None = await _repository.get_api_key(
+ app,
+ api_key_id=api_key_id,
+ user_id=user_id,
+ product_name=product_name,
+ )
+ if api_key is not None:
+ return api_key
+
+ raise ApiKeyNotFoundError(api_key_id=api_key_id)
+
+
+async def get_or_create_api_key(
+ app: web.Application,
+ *,
+ user_id: UserID,
+ product_name: ProductName,
+ display_name: str,
+ expiration: dt.timedelta | None = None,
+) -> ApiKey:
+
+ key, secret = _generate_api_key_and_secret(display_name)
+
+ api_key: ApiKey = await _repository.get_or_create_api_key(
+ app,
+ user_id=user_id,
+ product_name=product_name,
+ display_name=display_name,
+ expiration=expiration,
+ api_key=key,
+ api_secret=secret,
+ )
+
+ return api_key
+
+
+async def delete_api_key(
+ app: web.Application,
+ *,
+ api_key_id: str,
+ user_id: UserID,
+ product_name: ProductName,
+) -> None:
+ await _repository.delete_api_key(
+ app,
+ api_key_id=api_key_id,
+ user_id=user_id,
+ product_name=product_name,
+ )
+
+
+async def prune_expired_api_keys(app: web.Application) -> list[str]:
+ names: list[str] = await _repository.prune_expired(app)
+ return names
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/api.py b/services/web/server/src/simcore_service_webserver/api_keys/api.py
index df9cb9c8498..9c6a11d719d 100644
--- a/services/web/server/src/simcore_service_webserver/api_keys/api.py
+++ b/services/web/server/src/simcore_service_webserver/api_keys/api.py
@@ -1,4 +1,4 @@
-from ._api import prune_expired_api_keys
+from ._service import prune_expired_api_keys
__all__: tuple[str, ...] = ("prune_expired_api_keys",)
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/errors.py b/services/web/server/src/simcore_service_webserver/api_keys/errors.py
new file mode 100644
index 00000000000..5fbe6c38bd9
--- /dev/null
+++ b/services/web/server/src/simcore_service_webserver/api_keys/errors.py
@@ -0,0 +1,13 @@
+from ..errors import WebServerBaseError
+
+
+class ApiKeysValueError(WebServerBaseError, ValueError):
+ ...
+
+
+class ApiKeyDuplicatedDisplayNameError(ApiKeysValueError):
+ msg_template = "API Key with display name '{display_name}' already exists. {reason}"
+
+
+class ApiKeyNotFoundError(ApiKeysValueError):
+ msg_template = "API Key with ID '{api_key_id}' not found. {reason}"
diff --git a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py
index b2871596094..9c8cc742c23 100644
--- a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py
+++ b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py
@@ -8,7 +8,7 @@
from ..products.plugin import setup_products
from ..rabbitmq import setup_rabbitmq
from ..rest.plugin import setup_rest
-from . import _handlers, _rpc
+from . import _rest, _rpc
_logger = logging.getLogger(__name__)
@@ -26,7 +26,7 @@ def setup_api_keys(app: web.Application):
# http api
setup_rest(app)
- app.router.add_routes(_handlers.routes)
+ app.router.add_routes(_rest.routes)
# rpc api
setup_rabbitmq(app)
diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py
index aa9e1a14065..85f63c42b96 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py
@@ -7,19 +7,21 @@
from collections.abc import AsyncIterable
from datetime import timedelta
from http import HTTPStatus
+from http.client import HTTPException
import pytest
from aiohttp.test_utils import TestClient
+from faker import Faker
from models_library.products import ProductName
from pytest_simcore.helpers.assert_checks import assert_status
from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict
from servicelib.aiohttp import status
-from simcore_service_webserver.api_keys._api import (
- get_api_key,
+from simcore_service_webserver.api_keys import _repository as repo
+from simcore_service_webserver.api_keys._models import ApiKey
+from simcore_service_webserver.api_keys._service import (
get_or_create_api_key,
prune_expired_api_keys,
)
-from simcore_service_webserver.api_keys._db import ApiKeyRepo
from simcore_service_webserver.db.models import UserRole
@@ -28,26 +30,28 @@ async def fake_user_api_keys(
client: TestClient,
logged_user: UserInfoDict,
osparc_product_name: ProductName,
-) -> AsyncIterable[list[str]]:
+ faker: Faker,
+) -> AsyncIterable[list[int]]:
assert client.app
- names = ["foo", "bar", "beta", "alpha"]
- repo = ApiKeyRepo.create_from_app(app=client.app)
-
- for name in names:
- await repo.create(
+ api_keys: list[ApiKey] = [
+ await repo.create_api_key(
+ client.app,
user_id=logged_user["id"],
product_name=osparc_product_name,
- display_name=name,
+ display_name=faker.pystr(),
expiration=None,
- api_key=f"{name}-key",
- api_secret=f"{name}-secret",
+ api_key=faker.pystr(),
+ api_secret=faker.pystr(),
)
+ for _ in range(5)
+ ]
- yield names
+ yield api_keys
- for name in names:
- await repo.delete_by_name(
- display_name=name,
+ for api_key in api_keys:
+ await repo.delete_api_key(
+ client.app,
+ api_key_id=api_key.id,
user_id=logged_user["id"],
product_name=osparc_product_name,
)
@@ -87,7 +91,7 @@ async def test_list_api_keys(
"user_role,expected",
_get_user_access_parametrizations(status.HTTP_200_OK),
)
-async def test_create_api_keys(
+async def test_create_api_key(
client: TestClient,
logged_user: UserInfoDict,
user_role: UserRole,
@@ -95,18 +99,18 @@ async def test_create_api_keys(
disable_gc_manual_guest_users: None,
):
display_name = "foo"
- resp = await client.post("/v0/auth/api-keys", json={"display_name": display_name})
+ resp = await client.post("/v0/auth/api-keys", json={"displayName": display_name})
data, errors = await assert_status(resp, expected)
if not errors:
- assert data["display_name"] == display_name
- assert "api_key" in data
- assert "api_secret" in data
+ assert data["displayName"] == display_name
+ assert "apiKey" in data
+ assert "apiSecret" in data
resp = await client.get("/v0/auth/api-keys")
data, _ = await assert_status(resp, expected)
- assert sorted(data) == [display_name]
+ assert [d["displayName"] for d in data] == [display_name]
@pytest.mark.parametrize(
@@ -115,17 +119,17 @@ async def test_create_api_keys(
)
async def test_delete_api_keys(
client: TestClient,
- fake_user_api_keys: list[str],
+ fake_user_api_keys: list[ApiKey],
logged_user: UserInfoDict,
user_role: UserRole,
expected: HTTPStatus,
disable_gc_manual_guest_users: None,
):
- resp = await client.delete("/v0/auth/api-keys", json={"display_name": "foo"})
+ resp = await client.delete("/v0/auth/api-keys/0")
await assert_status(resp, expected)
- for name in fake_user_api_keys:
- resp = await client.delete("/v0/auth/api-keys", json={"display_name": name})
+ for api_key in fake_user_api_keys:
+ resp = await client.delete(f"/v0/auth/api-keys/{api_key.id}")
await assert_status(resp, expected)
@@ -146,19 +150,19 @@ async def test_create_api_key_with_expiration(
expiration_interval = timedelta(seconds=1)
resp = await client.post(
"/v0/auth/api-keys",
- json={"display_name": "foo", "expiration": expiration_interval.seconds},
+ json={"displayName": "foo", "expiration": expiration_interval.seconds},
)
data, errors = await assert_status(resp, expected)
if not errors:
- assert data["display_name"] == "foo"
- assert "api_key" in data
- assert "api_secret" in data
+ assert data["displayName"] == "foo"
+ assert "apiKey" in data
+ assert "apiSecret" in data
# list created api-key
resp = await client.get("/v0/auth/api-keys")
data, _ = await assert_status(resp, expected)
- assert data == ["foo"]
+ assert [d["displayName"] for d in data] == ["foo"]
# wait for api-key for it to expire and force-run scheduled task
await asyncio.sleep(expiration_interval.seconds)
@@ -180,19 +184,34 @@ async def test_get_or_create_api_key(
assert client.app
options = {
- "name": "repeated_name",
"user_id": user["id"],
"product_name": "osparc",
+ "display_name": "foo",
}
- # does not exist
- assert await get_api_key(client.app, **options) is None
-
# create once
created = await get_or_create_api_key(client.app, **options)
- assert created.display_name == options["name"]
+ assert created.display_name == "foo"
assert created.api_key != created.api_secret
- # idempottent
+ # idempotent
for _ in range(3):
assert await get_or_create_api_key(client.app, **options) == created
+
+
+@pytest.mark.parametrize(
+ "user_role,expected",
+ _get_user_access_parametrizations(status.HTTP_404_NOT_FOUND),
+)
+async def test_get_not_existing_api_key(
+ client: TestClient,
+ logged_user: UserInfoDict,
+ user_role: UserRole,
+ expected: HTTPException,
+ disable_gc_manual_guest_users: None,
+):
+ resp = await client.get("/v0/auth/api-keys/42")
+ data, errors = await assert_status(resp, expected)
+
+ if not errors:
+ assert data is None
diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py
index 51467f3c822..aa45fd9fd2e 100644
--- a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py
+++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py
@@ -8,19 +8,23 @@
import pytest
from aiohttp.test_utils import TestServer
from faker import Faker
-from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
-from models_library.api_schemas_webserver.auth import ApiKeyCreate
from models_library.products import ProductName
-from models_library.rabbitmq_basic_types import RPCMethodName
-from pydantic import TypeAdapter
+from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate
from pytest_mock import MockerFixture
from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict
from pytest_simcore.helpers.typing_env import EnvVarsDict
from pytest_simcore.helpers.webserver_login import UserInfoDict
from servicelib.rabbitmq import RabbitMQRPCClient
+from servicelib.rabbitmq.rpc_interfaces.webserver.auth.api_keys import (
+ create_api_key,
+ delete_api_key,
+ get_api_key,
+)
from settings_library.rabbit import RabbitSettings
from simcore_postgres_database.models.users import UserRole
-from simcore_service_webserver.api_keys._db import ApiKeyRepo
+from simcore_service_webserver.api_keys import _repository as repo
+from simcore_service_webserver.api_keys._models import ApiKey
+from simcore_service_webserver.api_keys.errors import ApiKeyNotFoundError
from simcore_service_webserver.application_settings import ApplicationSettings
pytest_simcore_core_services_selection = [
@@ -63,25 +67,29 @@ async def fake_user_api_keys(
web_server: TestServer,
logged_user: UserInfoDict,
osparc_product_name: ProductName,
-) -> AsyncIterable[list[str]]:
- names = ["foo", "bar", "beta", "alpha"]
- repo = ApiKeyRepo.create_from_app(app=web_server.app)
+ faker: Faker,
+) -> AsyncIterable[list[ApiKey]]:
+ assert web_server.app
- for name in names:
- await repo.create(
+ api_keys: list[ApiKey] = [
+ await repo.create_api_key(
+ web_server.app,
user_id=logged_user["id"],
product_name=osparc_product_name,
- display_name=name,
+ display_name=faker.pystr(),
expiration=None,
- api_key=f"{name}-key",
- api_secret=f"{name}-secret",
+ api_key=faker.pystr(),
+ api_secret=faker.pystr(),
)
+ for _ in range(5)
+ ]
- yield names
+ yield api_keys
- for name in names:
- await repo.delete_by_name(
- display_name=name,
+ for api_key in api_keys:
+ await repo.delete_api_key(
+ web_server.app,
+ api_key_id=api_key.id,
user_id=logged_user["id"],
product_name=osparc_product_name,
)
@@ -95,21 +103,20 @@ async def rpc_client(
return await rabbitmq_rpc_client("client")
-async def test_api_key_get(
- fake_user_api_keys: list[str],
+async def test_get_api_key(
+ fake_user_api_keys: list[ApiKey],
rpc_client: RabbitMQRPCClient,
osparc_product_name: ProductName,
logged_user: UserInfoDict,
):
- for api_key_name in fake_user_api_keys:
- result = await rpc_client.request(
- WEBSERVER_RPC_NAMESPACE,
- TypeAdapter(RPCMethodName).validate_python("api_key_get"),
- product_name=osparc_product_name,
+ for api_key in fake_user_api_keys:
+ result = await get_api_key(
+ rpc_client,
user_id=logged_user["id"],
- name=api_key_name,
+ product_name=osparc_product_name,
+ api_key_id=api_key.id,
)
- assert result.display_name == api_key_name
+ assert result.id == api_key.id
async def test_api_keys_workflow(
@@ -122,43 +129,39 @@ async def test_api_keys_workflow(
key_name = faker.pystr()
# creating a key
- created_api_key = await rpc_client.request(
- WEBSERVER_RPC_NAMESPACE,
- TypeAdapter(RPCMethodName).validate_python("create_api_keys"),
- product_name=osparc_product_name,
+ created_api_key = await create_api_key(
+ rpc_client,
user_id=logged_user["id"],
- new=ApiKeyCreate(display_name=key_name, expiration=None),
+ product_name=osparc_product_name,
+ api_key=ApiKeyCreate(display_name=key_name, expiration=None),
)
assert created_api_key.display_name == key_name
# query the key is still present
- queried_api_key = await rpc_client.request(
- WEBSERVER_RPC_NAMESPACE,
- TypeAdapter(RPCMethodName).validate_python("api_key_get"),
+ queried_api_key = await get_api_key(
+ rpc_client,
product_name=osparc_product_name,
user_id=logged_user["id"],
- name=key_name,
+ api_key_id=created_api_key.id,
)
assert queried_api_key.display_name == key_name
- assert created_api_key == queried_api_key
+ assert created_api_key.id == queried_api_key.id
+ assert created_api_key.display_name == queried_api_key.display_name
# remove the key
- delete_key_result = await rpc_client.request(
- WEBSERVER_RPC_NAMESPACE,
- TypeAdapter(RPCMethodName).validate_python("delete_api_keys"),
- product_name=osparc_product_name,
+ await delete_api_key(
+ rpc_client,
user_id=logged_user["id"],
- name=key_name,
- )
- assert delete_key_result is None
-
- # key no longer present
- query_missing_query = await rpc_client.request(
- WEBSERVER_RPC_NAMESPACE,
- TypeAdapter(RPCMethodName).validate_python("api_key_get"),
product_name=osparc_product_name,
- user_id=logged_user["id"],
- name=key_name,
+ api_key_id=created_api_key.id,
)
- assert query_missing_query is None
+
+ with pytest.raises(ApiKeyNotFoundError):
+ # key no longer present
+ await get_api_key(
+ rpc_client,
+ product_name=osparc_product_name,
+ user_id=logged_user["id"],
+ api_key_id=created_api_key.id,
+ )
diff --git a/tests/public-api/conftest.py b/tests/public-api/conftest.py
index 3b4a0b27b9c..b8f40710e08 100644
--- a/tests/public-api/conftest.py
+++ b/tests/public-api/conftest.py
@@ -147,25 +147,23 @@ def registered_user(
resp.raise_for_status()
# create a key via web-api
- resp = client.post("/auth/api-keys", json={"display_name": "test-public-api"})
+ resp = client.post("/auth/api-keys", json={"displayName": "test-public-api"})
print(resp.text)
resp.raise_for_status()
data = resp.json()["data"]
- assert data["display_name"] == "test-public-api"
+ assert data["displayName"] == "test-public-api"
- assert "api_key" in data
- assert "api_secret" in data
+ assert "apiKey" in data
+ assert "apiSecret" in data
- user["api_key"] = data["api_key"]
- user["api_secret"] = data["api_secret"]
+ user["api_key"] = data["apiKey"]
+ user["api_secret"] = data["apiSecret"]
yield user
- resp = client.request(
- "DELETE", "/auth/api-keys", json={"display_name": "test-public-api"}
- )
+ resp = client.delete(f"/auth/api-keys/{data['id']}")
@pytest.fixture(scope="module")
@@ -283,7 +281,7 @@ def api_client(
def as_dict(obj: object):
return {
attr: getattr(obj, attr)
- for attr in obj.__dict__.keys()
+ for attr in obj.__dict__
if not attr.startswith("_")
}