Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Implements study read operations in api-server #4511

Merged
merged 14 commits into from
Jul 24, 2023
3 changes: 2 additions & 1 deletion .env-devel
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ WEBSERVER_STUDIES_ACCESS_ENABLED=0
# For development ONLY ---------------
#
# AIODEBUG_SLOW_DURATION_SECS=0.25
# API_SERVER_DEV_FEATURES_ENABLED=1
# API_SERVER_DEV_HTTP_CALLS_LOGS_PATH=captures.ignore.keep.log
# PYTHONTRACEMALLOC=1
# PYTHONASYNCIODEBUG=1
# PYTHONTRACEMALLOC=1
#
# ------------------------------------
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@

from typing import Any

from pydantic import BaseModel, Extra, Field
from pydantic import BaseModel, Extra

from ..utils.change_case import snake_to_camel

NOT_REQUIRED = Field(default=None)


class EmptyModel(BaseModel):
# Used to represent body={}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
from ..projects_state import ProjectState
from ..projects_ui import StudyUI
from ..utils.common_validators import empty_str_to_none, none_to_empty_str
from ._base import NOT_REQUIRED, EmptyModel, InputSchema, OutputSchema
from ..utils.pydantic_tools_extension import FieldNotRequired
from ._base import EmptyModel, InputSchema, OutputSchema
from .permalinks import ProjectPermalink


Expand Down Expand Up @@ -65,7 +66,7 @@ class ProjectGet(OutputSchema):
ui: EmptyModel | StudyUI | None
quality: dict[str, Any] = {}
dev: dict | None
permalink: ProjectPermalink = NOT_REQUIRED
permalink: ProjectPermalink = FieldNotRequired()

_empty_description = validator("description", allow_reuse=True, pre=True)(
none_to_empty_str
Expand Down Expand Up @@ -103,15 +104,15 @@ class ProjectReplace(InputSchema):


class ProjectUpdate(InputSchema):
name: str = NOT_REQUIRED
description: str = NOT_REQUIRED
thumbnail: HttpUrlWithCustomMinLength = NOT_REQUIRED
workbench: NodesDict = NOT_REQUIRED
access_rights: dict[GroupIDStr, AccessRights] = NOT_REQUIRED
tags: list[int] = NOT_REQUIRED
classifiers: list[ClassifierID] = NOT_REQUIRED
name: str = FieldNotRequired()
description: str = FieldNotRequired()
thumbnail: HttpUrlWithCustomMinLength = FieldNotRequired()
workbench: NodesDict = FieldNotRequired()
access_rights: dict[GroupIDStr, AccessRights] = FieldNotRequired()
tags: list[int] = FieldNotRequired()
classifiers: list[ClassifierID] = FieldNotRequired()
ui: StudyUI | None = None
quality: dict[str, Any] = NOT_REQUIRED
quality: dict[str, Any] = FieldNotRequired()


__all__: tuple[str, ...] = (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import functools
from typing import TypeVar

from pydantic import ValidationError
from pydantic import Field, ValidationError
from pydantic.tools import parse_obj_as

T = TypeVar("T")
Expand All @@ -11,3 +12,9 @@ def parse_obj_or_none(type_: type[T], obj) -> T | None:
return parse_obj_as(type_, obj)
except ValidationError:
return None


#
# NOTE: Helper to define non-nullable optional fields
# SEE details in test/test_utils_pydantic_tools_extension.py
FieldNotRequired = functools.partial(Field, default=None)
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from models_library.utils.pydantic_tools_extension import (
FieldNotRequired,
parse_obj_or_none,
)
from pydantic import BaseModel, Field, StrictInt


class MyModel(BaseModel):
a: int
b: int | None = Field(...)
c: int = 42
d: int | None = None
e: int = FieldNotRequired(description="optional non-nullable")


def test_schema():
assert MyModel.schema() == {
"title": "MyModel",
"type": "object",
"properties": {
"a": {"title": "A", "type": "integer"},
"b": {"title": "B", "type": "integer"},
"c": {"title": "C", "default": 42, "type": "integer"},
"d": {"title": "D", "type": "integer"},
"e": {
"title": "E",
"type": "integer",
"description": "optional non-nullable",
},
},
"required": ["a", "b"],
}


def test_only_required():
model = MyModel(a=1, b=2)
assert model.dict() == {"a": 1, "b": 2, "c": 42, "d": None, "e": None}
assert model.dict(exclude_unset=True) == {"a": 1, "b": 2}


def test_parse_obj_or_none():
assert parse_obj_or_none(StrictInt, 42) == 42
assert parse_obj_or_none(StrictInt, 3.14) is None
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
from collections.abc import Callable
from typing import Any

from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from starlette.requests import Request
from starlette.responses import JSONResponse


class ErrorGet(BaseModel):
# We intentionally keep it open until more restrictive policy is implemented
# Check use cases:
# - https://github.com/ITISFoundation/osparc-issues/issues/958
# - https://github.com/ITISFoundation/osparc-simcore/issues/2520
# - https://github.com/ITISFoundation/osparc-simcore/issues/2446
errors: list[Any]
from ...models.schemas.errors import ErrorGet


def create_error_json_response(*errors, status_code: int) -> JSONResponse:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import io
import logging
from textwrap import dedent
from typing import IO, Annotated
from typing import IO, Annotated, Final
from uuid import UUID

from fastapi import APIRouter, Depends
Expand All @@ -24,12 +24,12 @@
from starlette.responses import RedirectResponse

from ..._meta import API_VTAG
from ...models.pagination import LimitOffsetPage, LimitOffsetParams
from ...models.pagination import Page, PaginationParams
from ...models.schemas.errors import ErrorGet
from ...models.schemas.files import File
from ...services.storage import StorageApi, StorageFileMetaData, to_file_api_model
from ..dependencies.authentication import get_current_user_id
from ..dependencies.services import get_api_client
from ..errors.http_error import ErrorGet
from ._common import API_SERVER_DEV_FEATURES_ENABLED

_logger = logging.getLogger(__name__)
Expand All @@ -42,7 +42,7 @@
#
#

_common_error_responses = {
_COMMON_ERROR_RESPONSES: Final[dict] = {
status.HTTP_404_NOT_FOUND: {
"description": "File not found",
"model": ErrorGet,
Expand Down Expand Up @@ -86,13 +86,13 @@ async def list_files(

@router.get(
"/page",
response_model=LimitOffsetPage[File],
response_model=Page[File],
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def get_files_page(
storage_client: Annotated[StorageApi, Depends(get_api_client(StorageApi))],
user_id: Annotated[int, Depends(get_current_user_id)],
page_params: Annotated[LimitOffsetParams, Depends()],
page_params: Annotated[PaginationParams, Depends()],
):
assert storage_client # nosec
assert user_id # nosec
Expand Down Expand Up @@ -170,7 +170,7 @@ async def upload_files(files: list[UploadFile] = FileParam(...)):
raise NotImplementedError


@router.get("/{file_id}", response_model=File, responses={**_common_error_responses})
@router.get("/{file_id}", response_model=File, responses={**_COMMON_ERROR_RESPONSES})
async def get_file(
file_id: UUID,
storage_client: Annotated[StorageApi, Depends(get_api_client(StorageApi))],
Expand Down Expand Up @@ -203,7 +203,7 @@ async def get_file(
@router.delete(
"/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses={**_common_error_responses},
responses={**_COMMON_ERROR_RESPONSES},
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def delete_file(
Expand All @@ -221,7 +221,7 @@ async def delete_file(
"/{file_id}/content",
response_class=RedirectResponse,
responses={
**_common_error_responses,
**_COMMON_ERROR_RESPONSES,
200: {
"content": {
"application/octet-stream": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from servicelib.error_codes import create_error_code

from ...models.basic_types import VersionStr
from ...models.pagination import LimitOffsetPage, LimitOffsetParams, OnePage
from ...models.pagination import OnePage, Page, PaginationParams
from ...models.schemas.solvers import Solver, SolverKeyId, SolverPort
from ...services.catalog import CatalogApi
from ..dependencies.application import get_product_name, get_reverse_url_mapper
Expand Down Expand Up @@ -57,11 +57,11 @@ async def list_solvers(

@router.get(
"/page",
response_model=LimitOffsetPage[Solver],
response_model=Page[Solver],
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def get_solvers_page(
page_params: Annotated[LimitOffsetParams, Depends()],
page_params: Annotated[PaginationParams, Depends()],
):
msg = f"list solvers with pagination={page_params!r}"
raise NotImplementedError(msg)
Expand Down Expand Up @@ -94,11 +94,11 @@ async def list_solvers_releases(

@router.get(
"/releases/page",
response_model=LimitOffsetPage[Solver],
response_model=Page[Solver],
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def get_solvers_releases_page(
page_params: Annotated[LimitOffsetParams, Depends()],
page_params: Annotated[PaginationParams, Depends()],
):
msg = f"list solvers releases with pagination={page_params!r}"
raise NotImplementedError(msg)
Expand Down Expand Up @@ -162,12 +162,12 @@ async def list_solver_releases(

@router.get(
"/{solver_key:path}/releases/page",
response_model=LimitOffsetPage[Solver],
response_model=Page[Solver],
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def get_solver_releases_page(
solver_key: SolverKeyId,
page_params: Annotated[LimitOffsetParams, Depends()],
page_params: Annotated[PaginationParams, Depends()],
):
msg = f"list solver {solver_key=} (one) releases with pagination={page_params!r}"
raise NotImplementedError(msg)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
from collections import deque
from collections.abc import Callable
from typing import Annotated
from typing import Annotated, Final
from uuid import UUID

from fastapi import APIRouter, Depends, status
Expand All @@ -16,7 +16,8 @@
from pydantic.types import PositiveInt

from ...models.basic_types import VersionStr
from ...models.pagination import LimitOffsetPage, LimitOffsetParams
from ...models.pagination import Page, PaginationParams
from ...models.schemas.errors import ErrorGet
from ...models.schemas.files import File
from ...models.schemas.jobs import (
ArgumentTypes,
Expand All @@ -43,7 +44,7 @@
from ..dependencies.database import Engine, get_db_engine
from ..dependencies.services import get_api_client
from ..dependencies.webserver import AuthSession, get_webserver_session
from ..errors.http_error import ErrorGet, create_error_json_response
from ..errors.http_error import create_error_json_response
from ._common import API_SERVER_DEV_FEATURES_ENABLED, job_output_logfile_responses

_logger = logging.getLogger(__name__)
Expand All @@ -64,7 +65,7 @@ def _compose_job_resource_name(solver_key, solver_version, job_id) -> str:
# - Similar to docker container's API design (container = job and image = solver)
#

_common_error_responses = {
_COMMON_ERROR_RESPONSES: Final[dict] = {
status.HTTP_404_NOT_FOUND: {
"description": "Job not found",
"model": ErrorGet,
Expand Down Expand Up @@ -113,14 +114,14 @@ async def list_jobs(

@router.get(
"/{solver_key:path}/releases/{version}/jobs/page",
response_model=LimitOffsetPage[Job],
response_model=Page[Job],
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def get_jobs_page(
solver_key: SolverKeyId,
version: VersionStr,
user_id: Annotated[PositiveInt, Depends(get_current_user_id)],
page_params: Annotated[LimitOffsetParams, Depends()],
page_params: Annotated[PaginationParams, Depends()],
catalog_client: Annotated[CatalogApi, Depends(get_api_client(CatalogApi))],
webserver_api: Annotated[AuthSession, Depends(get_webserver_session)],
url_for: Annotated[Callable, Depends(get_reverse_url_mapper)],
Expand Down Expand Up @@ -442,7 +443,7 @@ async def get_job_output_logfile(
@router.get(
"/{solver_key:path}/releases/{version}/jobs/{job_id:uuid}/metadata",
response_model=JobMetadata,
responses={**_common_error_responses},
responses={**_COMMON_ERROR_RESPONSES},
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def get_job_custom_metadata(
Expand Down Expand Up @@ -483,7 +484,7 @@ async def get_job_custom_metadata(
@router.patch(
"/{solver_key:path}/releases/{version}/jobs/{job_id:uuid}/metadata",
response_model=JobMetadata,
responses={**_common_error_responses},
responses={**_COMMON_ERROR_RESPONSES},
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
)
async def replace_job_custom_metadata(
Expand Down
Loading