diff --git a/README.md b/README.md index 7494725c..ecc53d43 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ def add_routes(app: FastAPI): router: APIRouter = APIRouter() RoutersJSONAPI( router=router, - path="/user", + path="/users", tags=["User"], class_detail=UserDetailView, class_list=UserListView, @@ -195,11 +195,11 @@ if __name__ == "__main__": This example provides the following API structure: -| URL | method | endpoint | Usage | -|------------------|--------|-------------|---------------------------| -| `/user` | GET | user_list | Get a collection of users | -| `/user` | POST | user_list | Create a user | -| `/user` | DELETE | user_list | Delete users | -| `/user/{obj_id}` | GET | user_detail | Get user details | -| `/user/{obj_id}` | PATCH | user_detail | Update a user | -| `/user/{obj_id}` | DELETE | user_detail | Delete a user | +| URL | method | endpoint | Usage | +|-------------------|--------|-------------|---------------------------| +| `/users` | GET | user_list | Get a collection of users | +| `/users` | POST | user_list | Create a user | +| `/users` | DELETE | user_list | Delete users | +| `/users/{obj_id}` | GET | user_detail | Get user details | +| `/users/{obj_id}` | PATCH | user_detail | Update a user | +| `/users/{obj_id}` | DELETE | user_detail | Delete a user | diff --git a/docs/api_limited_methods_example.rst b/docs/api_limited_methods_example.rst new file mode 100644 index 00000000..561b58d9 --- /dev/null +++ b/docs/api_limited_methods_example.rst @@ -0,0 +1,46 @@ +.. _api_limited_methods_example: + +Limit API methods +################# + +Sometimes you won't need all the CRUD methods. +For example, you want to create only GET, POST and GET LIST methods, +so user can't update or delete any items. + + +Set ``methods`` on Routers registration: + +.. code-block:: python + + RoutersJSONAPI( + router=router, + path="/users", + tags=["User"], + class_detail=UserDetailView, + class_list=UserListView, + schema=UserSchema, + model=User, + resource_type="user", + methods=[ + RoutersJSONAPI.Methods.GET_LIST, + RoutersJSONAPI.Methods.POST, + RoutersJSONAPI.Methods.GET, + ], + ) + + +This will limit generated views to: + +======================== ====== ============= =========================== +URL method endpoint Usage +======================== ====== ============= =========================== +/users GET user_list Get a collection of users +/users POST user_list Create a user +/users/{user_id} GET user_detail Get user details +======================== ====== ============= =========================== + + +Full code example (should run "as is"): + +.. literalinclude:: ../examples/api_limited_methods.py + :language: python diff --git a/docs/filtering.rst b/docs/filtering.rst index 000d63dd..1b8493ca 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -24,7 +24,7 @@ So this is a first example: .. sourcecode:: http - GET /user?filter=[{"name":"first_name","op":"eq","val":"John"}] HTTP/1.1 + GET /users?filter=[{"name":"first_name","op":"eq","val":"John"}] HTTP/1.1 Accept: application/vnd.api+json In this example we want to retrieve user records for people named John. So we can see that the filtering interface completely fits that of SQLAlchemy: a list a filter information. @@ -37,7 +37,7 @@ Example with field: .. sourcecode:: http - GET /user?filter=[{"name":"first_name","op":"eq","field":"birth_date"}] HTTP/1.1 + GET /users?filter=[{"name":"first_name","op":"eq","field":"birth_date"}] HTTP/1.1 Accept: application/vnd.api+json In this example, we want to retrieve people whose name is equal to their birth_date. This example is absurd, it's just here to explain the syntax of this kind of filter. @@ -74,7 +74,7 @@ There is a shortcut to achieve the same filtering: .. sourcecode:: http - GET /user?filter=[{"name":"group.name","op":"ilike","val":"%admin%"}] HTTP/1.1 + GET /users?filter=[{"name":"group.name","op":"ilike","val":"%admin%"}] HTTP/1.1 Accept: application/vnd.api+json You can also use boolean combination of operations: @@ -116,7 +116,7 @@ You can also use boolean combination of operations: .. sourcecode:: http - GET /user?filter=[{"name":"group.name","op":"ilike","val":"%admin%"},{"or":[{"not":{"name":"first_name","op":"eq","val":"John"}},{"and":[{"name":"first_name","op":"like","val":"%Jim%"},{"name":"date_create","op":"gt","val":"1990-01-01"}]}]}] HTTP/1.1 + GET /users?filter=[{"name":"group.name","op":"ilike","val":"%admin%"},{"or":[{"not":{"name":"first_name","op":"eq","val":"John"}},{"and":[{"name":"first_name","op":"like","val":"%Jim%"},{"name":"date_create","op":"gt","val":"1990-01-01"}]}]}] HTTP/1.1 Accept: application/vnd.api+json @@ -124,14 +124,14 @@ Filtering records by a field that is null .. sourcecode:: http - GET /user?filter=[{"name":"name","op":"is_","val":null}] HTTP/1.1 + GET /users?filter=[{"name":"name","op":"is_","val":null}] HTTP/1.1 Accept: application/vnd.api+json Filtering records by a field that is not null .. sourcecode:: http - GET /user?filter=[{"name":"name","op":"isnot","val":null}] HTTP/1.1 + GET /users?filter=[{"name":"name","op":"isnot","val":null}] HTTP/1.1 Accept: application/vnd.api+json @@ -172,14 +172,14 @@ For example .. sourcecode:: http - GET /user?filter[first_name]=John HTTP/1.1 + GET /users?filter[first_name]=John HTTP/1.1 Accept: application/vnd.api+json equals: .. sourcecode:: http - GET /user?filter=[{"name":"first_name","op":"eq","val":"John"}] HTTP/1.1 + GET /users?filter=[{"name":"first_name","op":"eq","val":"John"}] HTTP/1.1 Accept: application/vnd.api+json @@ -187,7 +187,7 @@ You can also use more than one simple filter in a request: .. sourcecode:: http - GET /user?filter[first_name]=John&filter[gender]=male HTTP/1.1 + GET /users?filter[first_name]=John&filter[gender]=male HTTP/1.1 Accept: application/vnd.api+json which is equal to: @@ -209,17 +209,17 @@ which is equal to: .. sourcecode:: http - GET /user?filter=[{"name":"first_name","op":"eq","val":"John"},{"name":"gender","op":"eq","val":"male"}] HTTP/1.1 + GET /users?filter=[{"name":"first_name","op":"eq","val":"John"},{"name":"gender","op":"eq","val":"male"}] HTTP/1.1 You can also use relationship attribute in a request: .. sourcecode:: http - GET /user?filter[group_id]=1 HTTP/1.1 + GET /users?filter[group_id]=1 HTTP/1.1 Accept: application/vnd.api+json which is equal to: .. sourcecode:: http - GET /user?filter=[{"name":"group.id","op":"eq","val":"1"}] HTTP/1.1 + GET /users?filter=[{"name":"group.id","op":"eq","val":"1"}] HTTP/1.1 diff --git a/docs/index.rst b/docs/index.rst index 872b3c04..209c2d9f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -61,6 +61,7 @@ FastAPI-JSONAPI with FastAPI. minimal_api_example api_filtering_example quickstart + api_limited_methods_example routing atomic_operations view_dependencies diff --git a/docs/python_snippets/client_generated_id/schematic_example.py b/docs/python_snippets/client_generated_id/schematic_example.py index c8f8b26d..ed632748 100644 --- a/docs/python_snippets/client_generated_id/schematic_example.py +++ b/docs/python_snippets/client_generated_id/schematic_example.py @@ -110,7 +110,7 @@ def add_routes(app: FastAPI): router: APIRouter = APIRouter() RoutersJSONAPI( router=router, - path="/user", + path="/users", tags=["User"], class_detail=UserDetailView, class_list=UserListView, diff --git a/docs/quickstart.rst b/docs/quickstart.rst index e667b132..7d87e884 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -27,15 +27,15 @@ This example provides the following API: +----------------+--------+----------------+---------------------------------+ | url | method | endpoint | action | +================+========+================+=================================+ -| /user | GET | user_list | Retrieve a collection of users | +| /users | GET | user_list | Retrieve a collection of users | +----------------+--------+----------------+---------------------------------+ -| /user | POST | user_list | Create a user | +| /users | POST | user_list | Create a user | +----------------+--------+----------------+---------------------------------+ -| /user/ | GET | user_detail | Retrieve details of a user | +| /users/ | GET | user_detail | Retrieve details of a user | +----------------+--------+----------------+---------------------------------+ -| /user/ | PATCH | user_detail | Update a user | +| /users/ | PATCH | user_detail | Update a user | +----------------+--------+----------------+---------------------------------+ -| /user/ | DELETE | user_detail | Delete a user | +| /users/ | DELETE | user_detail | Delete a user | +----------------+--------+----------------+---------------------------------+ in developing @@ -43,11 +43,11 @@ in developing +-------------------------------------------+--------+------------------+------------------------------------------------------+ | url | method | endpoint | action | +===========================================+========+==================+======================================================+ -| /user//group | GET | computer_list | Retrieve a collection computers related to a user | +| /users//group | GET | computer_list | Retrieve a collection computers related to a user | +-------------------------------------------+--------+------------------+------------------------------------------------------+ -| /user//group | POST | computer_list | Create a computer related to a user | +| /users//group | POST | computer_list | Create a computer related to a user | +-------------------------------------------+--------+------------------+------------------------------------------------------+ -| /user//relationships/group | GET | user_computers | Retrieve relationships between a user and computers | +| /users//relationships/group | GET | user_computers | Retrieve relationships between a user and computers | +-------------------------------------------+--------+------------------+------------------------------------------------------+ | /users//relationships/computers | POST | user_computers | Create relationships between a user and computers | +-------------------------------------------+--------+------------------+------------------------------------------------------+ diff --git a/examples/api_for_tortoise_orm/urls.py b/examples/api_for_tortoise_orm/urls.py index 9037b44a..f35f8bd1 100644 --- a/examples/api_for_tortoise_orm/urls.py +++ b/examples/api_for_tortoise_orm/urls.py @@ -36,7 +36,7 @@ def add_routes(app: FastAPI) -> List[Dict[str, Any]]: # TODO: fix example RoutersJSONAPI( router=routers, - path="/user", + path="/users", tags=["User"], class_detail=UserDetail, class_list=UserList, diff --git a/examples/api_limited_methods.py b/examples/api_limited_methods.py new file mode 100644 index 00000000..b2a88a06 --- /dev/null +++ b/examples/api_limited_methods.py @@ -0,0 +1,156 @@ +import sys +from pathlib import Path +from typing import Any, Dict + +import uvicorn +from fastapi import APIRouter, Depends, FastAPI +from sqlalchemy import Column, Integer, Text +from sqlalchemy.engine import make_url +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from fastapi_jsonapi import RoutersJSONAPI, init +from fastapi_jsonapi.misc.sqla.generics.base import DetailViewBaseGeneric, ListViewBaseGeneric +from fastapi_jsonapi.schema_base import BaseModel +from fastapi_jsonapi.views.utils import HTTPMethod, HTTPMethodConfig +from fastapi_jsonapi.views.view_base import ViewBase + +CURRENT_FILE = Path(__file__).resolve() +CURRENT_DIR = CURRENT_FILE.parent +PROJECT_DIR = CURRENT_DIR.parent.parent +DB_URL = f"sqlite+aiosqlite:///{CURRENT_DIR}/db.sqlite3" +sys.path.append(str(PROJECT_DIR)) + +Base = declarative_base() + + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + name = Column(Text, nullable=True) + + +class UserAttributesBaseSchema(BaseModel): + name: str + + class Config: + orm_mode = True + + +class UserSchema(UserAttributesBaseSchema): + """User base schema.""" + + +def async_session() -> sessionmaker: + engine = create_async_engine(url=make_url(DB_URL)) + _async_session = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) + return _async_session + + +class Connector: + @classmethod + async def get_session(cls): + """ + Get session as dependency + + :return: + """ + sess = async_session() + async with sess() as db_session: # type: AsyncSession + yield db_session + await db_session.rollback() + + +async def sqlalchemy_init() -> None: + engine = create_async_engine(url=make_url(DB_URL)) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +class SessionDependency(BaseModel): + session: AsyncSession = Depends(Connector.get_session) + + class Config: + arbitrary_types_allowed = True + + +def session_dependency_handler(view: ViewBase, dto: SessionDependency) -> Dict[str, Any]: + return { + "session": dto.session, + } + + +class UserDetailView(DetailViewBaseGeneric): + method_dependencies = { + HTTPMethod.ALL: HTTPMethodConfig( + dependencies=SessionDependency, + prepare_data_layer_kwargs=session_dependency_handler, + ), + } + + +class UserListView(ListViewBaseGeneric): + method_dependencies = { + HTTPMethod.ALL: HTTPMethodConfig( + dependencies=SessionDependency, + prepare_data_layer_kwargs=session_dependency_handler, + ), + } + + +def add_routes(app: FastAPI): + tags = [ + { + "name": "User", + "description": "", + }, + ] + + router: APIRouter = APIRouter() + RoutersJSONAPI( + router=router, + path="/users", + tags=["User"], + class_detail=UserDetailView, + class_list=UserListView, + schema=UserSchema, + model=User, + resource_type="user", + methods=[ + RoutersJSONAPI.Methods.GET_LIST, + RoutersJSONAPI.Methods.POST, + RoutersJSONAPI.Methods.GET, + ], + ) + + app.include_router(router, prefix="") + return tags + + +def create_app() -> FastAPI: + """ + Create app factory. + + :return: app + """ + app = FastAPI( + title="FastAPI app with limited methods", + debug=True, + openapi_url="/openapi.json", + docs_url="/docs", + ) + add_routes(app) + app.on_event("startup")(sqlalchemy_init) + init(app) + return app + + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + app, + host="0.0.0.0", + port=8080, + ) diff --git a/examples/api_minimal.py b/examples/api_minimal.py index 488bd532..51d20528 100644 --- a/examples/api_minimal.py +++ b/examples/api_minimal.py @@ -42,14 +42,6 @@ class UserSchema(UserAttributesBaseSchema): """User base schema.""" -class UserPatchSchema(UserAttributesBaseSchema): - """User PATCH schema.""" - - -class UserInSchema(UserAttributesBaseSchema): - """User input schema.""" - - def async_session() -> sessionmaker: engine = create_async_engine(url=make_url(DB_URL)) _async_session = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) @@ -118,7 +110,7 @@ def add_routes(app: FastAPI): router: APIRouter = APIRouter() RoutersJSONAPI( router=router, - path="/user", + path="/users", tags=["User"], class_detail=UserDetailView, class_list=UserListView, @@ -153,9 +145,7 @@ def create_app() -> FastAPI: if __name__ == "__main__": uvicorn.run( - "main:app", + app, host="0.0.0.0", port=8080, - reload=True, - app_dir=str(CURRENT_DIR), ) diff --git a/fastapi_jsonapi/api.py b/fastapi_jsonapi/api.py index 8f57507a..a3c9b1f7 100644 --- a/fastapi_jsonapi/api.py +++ b/fastapi_jsonapi/api.py @@ -1,4 +1,5 @@ """JSON API router class.""" +from enum import Enum, auto from inspect import Parameter, Signature, signature from typing import ( TYPE_CHECKING, @@ -40,6 +41,15 @@ not_passed = object() +class ViewMethods(str, Enum): + GET_LIST = auto() + POST = auto() + DELETE_LIST = auto() + GET = auto() + DELETE = auto() + PATCH = auto() + + class RoutersJSONAPI: """ API Router interface for JSON API endpoints in web-services. @@ -47,6 +57,8 @@ class RoutersJSONAPI: # xxx: store in app, not in routers! all_jsonapi_routers: Dict[str, "RoutersJSONAPI"] = {} + Methods = ViewMethods + DEFAULT_METHODS = tuple(str(method) for method in ViewMethods) def __init__( self, @@ -64,6 +76,7 @@ def __init__( pagination_default_number: Optional[int] = 1, pagination_default_offset: Optional[int] = None, pagination_default_limit: Optional[int] = None, + methods: Iterable[str] = (), ) -> None: """ Initialize router items. @@ -101,6 +114,8 @@ def __init__( self.schema_list: Type[BaseModel] = schema self.model: Type[TypeModel] = model self.schema_detail = schema + # tuple and not set, so ordering is persisted + self.methods = tuple(methods) or self.DEFAULT_METHODS if self.type_ in self.all_jsonapi_routers: msg = f"Resource type {self.type_!r} already registered" @@ -664,10 +679,19 @@ def _register_views(self, path: str): :param path: :return: """ - self._register_get_resource_list(path) - self._register_post_resource_list(path) - self._register_delete_resource_list(path) - - self._register_get_resource_detail(path) - self._register_patch_resource_detail(path) - self._register_delete_resource_detail(path) + methods_map: Dict[Union[str, ViewMethods], Callable[[str], None]] = { + ViewMethods.GET_LIST: self._register_get_resource_list, + ViewMethods.POST: self._register_post_resource_list, + ViewMethods.DELETE_LIST: self._register_delete_resource_list, + ViewMethods.GET: self._register_get_resource_detail, + ViewMethods.PATCH: self._register_patch_resource_detail, + ViewMethods.DELETE: self._register_delete_resource_detail, + } + # patch for Python < 3.11 + for key, value in list(methods_map.items()): + methods_map[str(key)] = value + + for method in self.methods: + # `to str` so Python < 3.11 is supported + register = methods_map[str(method)] + register(path)