├── app
├── __init__.py
├── api
│ ├── __init__.py
│ ├── apps
│ │ ├── __init__.py
│ │ ├── admin_app.py
│ │ └── other_app.py
│ └── endpoints
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── other_api.py
│ │ ├── tasks.py
│ │ ├── develop.py
│ │ ├── auth.py
│ │ ├── users.py
│ │ └── todos.py
├── core
│ ├── __init__.py
│ ├── logger
│ │ ├── __init__.py
│ │ └── logger.py
│ ├── utils.py
│ ├── config.py
│ ├── auth.py
│ └── database.py
├── commands
│ ├── __init__.py
│ ├── __set_base_path__.py
│ └── user_creation.py
├── exceptions
│ ├── __init__.py
│ ├── exception_handlers.py
│ ├── core.py
│ └── error_messages.py
├── models
│ ├── __init__.py
│ ├── tags.py
│ ├── todos_tags.py
│ ├── todos.py
│ ├── users.py
│ └── base.py
├── crud
│ ├── __init__.py
│ ├── tag.py
│ ├── user.py
│ ├── todo.py
│ └── base.py
├── crud_v2
│ ├── __init__.py
│ ├── tag.py
│ ├── user.py
│ ├── todo.py
│ └── base.py
├── schemas
│ ├── request_info.py
│ ├── token.py
│ ├── __init__.py
│ ├── tag.py
│ ├── user.py
│ ├── language_analyzer.py
│ ├── todo.py
│ └── core.py
├── logger_config.yaml
├── app_manager.py
└── main.py
├── tests
├── __init__.py
├── todos
│ ├── __init__.py
│ ├── conftest.py
│ └── test_todos.py
├── pydantic_performances
│ ├── __init__.py
│ └── test_performances.py
├── base.py
├── testing_utils.py
└── conftest.py
├── .python-version
├── runtime.txt
├── .dockerignore
├── alembic
├── README
├── script.py.mako
├── env.py
└── versions
│ └── 20230131-0237_.py
├── docs
├── docs
│ ├── install.md
│ └── index.md
└── mkdocs.yml
├── frontend_sample
├── src
│ ├── api_clients
│ │ ├── .openapi-generator
│ │ │ ├── VERSION
│ │ │ └── FILES
│ │ ├── .gitignore
│ │ ├── .npmignore
│ │ ├── index.ts
│ │ ├── client.ts
│ │ ├── .openapi-generator-ignore
│ │ ├── base.ts
│ │ ├── git_push.sh
│ │ ├── configuration.ts
│ │ └── common.ts
│ ├── config.ts
│ ├── types
│ │ └── api
│ │ │ └── index.ts
│ ├── pages
│ │ ├── todos
│ │ │ ├── list.tsx
│ │ │ ├── create.tsx
│ │ │ └── edit.tsx
│ │ ├── _app.tsx
│ │ └── index.tsx
│ ├── styles
│ │ ├── globals.css
│ │ └── Home.module.css
│ ├── lib
│ │ └── hooks
│ │ │ └── api
│ │ │ ├── todos.ts
│ │ │ └── index.ts
│ └── components
│ │ └── templates
│ │ └── todos
│ │ ├── TodoCreateTemplate
│ │ └── TodoCreateTemplate.tsx
│ │ ├── TodoUpdateTemplate
│ │ └── TodoUpdateTemplate.tsx
│ │ └── TodosListTemplate
│ │ └── TodosListTemplate.tsx
├── .eslintrc.json
├── public
│ ├── favicon.ico
│ └── vercel.svg
├── next.config.js
├── .gitignore
├── package.json
├── tsconfig.json
└── README.md
├── elasticsearch
├── readme.md
├── elasticsearch
│ └── Dockerfile.es
├── logstash
│ ├── Dockerfile
│ └── pipeline
│ │ └── main.conf
└── docker-compose.yml
├── .flake8
├── .vscode
├── extensions.json
├── launch.json
├── settings.json
└── database-client-config.json
├── Procfile
├── .env.test
├── .env.example
├── bak_flake8.ini
├── docker-compose.ecs.yml
├── .docker
└── docker-compose.ecs.yml
├── Dockerfile
├── seeder
├── seeds_json
│ ├── todos.json
│ └── users.json
└── run.py
├── Dockerfile.lambda
├── LICENSE.md
├── mypy.ini
├── .aws
└── ecs-task-definition.json
├── Makefile
├── requirements.lock
├── requirements-dev.lock
├── .github
└── workflows
│ ├── test.yml
│ ├── push_ecr.yml
│ └── aws.yml
├── docker-compose.es.yml
├── docker-compose.yml
├── .pre-commit-config.yaml
├── .devcontainer
└── devcontainer.json
├── pyproject.toml
├── alembic.ini
├── .gitignore
└── readme.md
/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/api/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/todos/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.11.3
2 |
--------------------------------------------------------------------------------
/app/api/apps/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/api/endpoints/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/runtime.txt:
--------------------------------------------------------------------------------
1 | python-3.11.3
2 |
--------------------------------------------------------------------------------
/tests/pydantic_performances/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/node_modules
2 | **/.venv
3 |
--------------------------------------------------------------------------------
/alembic/README:
--------------------------------------------------------------------------------
1 | Generic single-database configuration.
2 |
--------------------------------------------------------------------------------
/docs/docs/install.md:
--------------------------------------------------------------------------------
1 | # Install
2 | インストール、環境構築について説明します。
3 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/.openapi-generator/VERSION:
--------------------------------------------------------------------------------
1 | 7.0.0-SNAPSHOT
2 |
--------------------------------------------------------------------------------
/frontend_sample/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/elasticsearch/readme.md:
--------------------------------------------------------------------------------
1 | wsl -d docker-desktop
2 | sysctl -w vm.max_map_count=262144
3 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/.gitignore:
--------------------------------------------------------------------------------
1 | wwwroot/*.js
2 | node_modules
3 | typings
4 | dist
5 |
--------------------------------------------------------------------------------
/app/exceptions/__init__.py:
--------------------------------------------------------------------------------
1 | from .core import * # noqa
2 | from .error_messages import * # noqa
3 |
--------------------------------------------------------------------------------
/app/core/logger/__init__.py:
--------------------------------------------------------------------------------
1 | from .logger import get_logger, init_gunicorn_uvicorn_logger, init_logger
2 |
--------------------------------------------------------------------------------
/frontend_sample/src/config.ts:
--------------------------------------------------------------------------------
1 | const API_HOST = process.env.NEXT_PUBLIC_API_HOST;
2 | export { API_HOST };
3 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 119
3 | exclude = __init__.py,alembic,tests
4 | ignore = E203,W503,W504
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "matangover.mypy",
4 | "charliermarsh.ruff"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/frontend_sample/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takashi-yoneya/fastapi-template/HEAD/frontend_sample/public/favicon.ico
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/.npmignore:
--------------------------------------------------------------------------------
1 | # empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm
2 |
--------------------------------------------------------------------------------
/app/models/__init__.py:
--------------------------------------------------------------------------------
1 | from .tags import Tag
2 | from .todos import Todo
3 | from .todos_tags import TodoTag
4 | from .users import User
5 |
--------------------------------------------------------------------------------
/app/commands/__set_base_path__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from pathlib import Path
3 |
4 | sys.path.append(str(Path(__file__).absolute().parent.parent))
5 |
--------------------------------------------------------------------------------
/app/crud/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import * # noqa
2 | from .tag import * # noqa
3 | from .todo import * # noqa
4 | from .user import * # noqa
5 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: uvicorn app.main:app --host=0.0.0.0 --port=${PORT:-5000} --log-config=./app/logger_config.yaml --proxy-headers --forwarded-allow-ips='*'
2 |
--------------------------------------------------------------------------------
/app/crud_v2/__init__.py:
--------------------------------------------------------------------------------
1 | from .base import * # noqa
2 | from .tag import * # noqa
3 | from .todo import * # noqa
4 | from .user import * # noqa
5 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/.openapi-generator/FILES:
--------------------------------------------------------------------------------
1 | .gitignore
2 | .npmignore
3 | api.ts
4 | base.ts
5 | common.ts
6 | configuration.ts
7 | git_push.sh
8 | index.ts
9 |
--------------------------------------------------------------------------------
/frontend_sample/src/types/api/index.ts:
--------------------------------------------------------------------------------
1 | interface ErrorResponse {
2 | errorCode?: string;
3 | errorMessage?: string;
4 | }
5 |
6 | export type { ErrorResponse };
7 |
--------------------------------------------------------------------------------
/.env.test:
--------------------------------------------------------------------------------
1 | ENV=local
2 | DEBUG=false
3 | SENTRY_SDK_DNS=""
4 | API_GATEWAY_STAGE_PATH=""
5 |
6 | DB_HOST=db
7 | DB_PORT=3306
8 | DB_NAME=test
9 | DB_USER_NAME=root
10 | DB_PASSWORD=
11 |
--------------------------------------------------------------------------------
/app/api/apps/admin_app.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 |
3 | from app.api.endpoints import admin
4 |
5 | app = FastAPI()
6 | app.include_router(admin.router, prefix="/admin", tags=["admin"])
7 |
--------------------------------------------------------------------------------
/frontend_sample/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | swcMinify: true,
5 | };
6 |
7 | module.exports = nextConfig;
8 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | ENV=local
2 | DEBUG=false
3 | SENTRY_SDK_DNS=""
4 | API_GATEWAY_STAGE_PATH=""
5 | DB_HOST=db
6 | DB_PORT=3306
7 | DB_NAME=docker
8 | DB_USER_NAME=docker
9 | DB_PASSWORD=docker
10 |
--------------------------------------------------------------------------------
/app/api/apps/other_app.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 |
3 | from app.api.endpoints import other_api
4 |
5 | app = FastAPI()
6 | app.include_router(other_api.router, prefix="/other", tags=["other"])
7 |
--------------------------------------------------------------------------------
/elasticsearch/elasticsearch/Dockerfile.es:
--------------------------------------------------------------------------------
1 | FROM docker.elastic.co/elasticsearch/elasticsearch:7.11.0
2 | RUN elasticsearch-plugin install analysis-kuromoji
3 | RUN elasticsearch-plugin install analysis-icu
4 |
--------------------------------------------------------------------------------
/bak_flake8.ini:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 88
3 | exclude = "__init__.py,alembic,tests,.git,__pycache__,.mypy_cache,.pytest_cache"
4 | ignore = "E203,W503,W504,E701"
5 | max-line-length = 88
6 | per-file-ignores = "__init__.py:F401"
7 |
--------------------------------------------------------------------------------
/app/schemas/request_info.py:
--------------------------------------------------------------------------------
1 | from pydantic import ConfigDict
2 |
3 | from app.schemas.core import BaseSchema
4 |
5 |
6 | class RequestInfoResponse(BaseSchema):
7 | model_config = ConfigDict(from_attributes=True)
8 | ip_address: str | None
9 | host: str | None
10 |
--------------------------------------------------------------------------------
/app/api/endpoints/admin.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 |
3 | from app.core.logger import get_logger
4 |
5 | logger = get_logger(__name__)
6 |
7 | router = APIRouter()
8 |
9 |
10 | @router.get("/hoge", operation_id="get_admin_hoge")
11 | async def get_admin_hoge() -> dict[str, str]:
12 | return {"response": "hoge"}
13 |
--------------------------------------------------------------------------------
/app/api/endpoints/other_api.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 |
3 | from app.core.logger import get_logger
4 |
5 | logger = get_logger(__name__)
6 |
7 | router = APIRouter()
8 |
9 |
10 | @router.get("/hoge", operation_id="get_other_hoge")
11 | async def get_other_hoge() -> dict[str, str]:
12 | return {"response": "hoge"}
13 |
--------------------------------------------------------------------------------
/frontend_sample/src/pages/todos/list.tsx:
--------------------------------------------------------------------------------
1 | import TodosListTemplate from "components/templates/todos/TodosListTemplate/TodosListTemplate";
2 | import { NextPage } from "next";
3 |
4 | const TodosListPage: NextPage = () => {
5 | return (
6 | <>
7 |
8 | >
9 | );
10 | };
11 |
12 | export default TodosListPage;
13 |
--------------------------------------------------------------------------------
/frontend_sample/src/pages/todos/create.tsx:
--------------------------------------------------------------------------------
1 | import TodoCreateTemplate from "components/templates/todos/TodoCreateTemplate/TodoCreateTemplate";
2 | import { NextPage } from "next";
3 |
4 | const TodoUpdatePage: NextPage = () => {
5 | return (
6 | <>
7 |
8 | >
9 | );
10 | };
11 |
12 | export default TodoUpdatePage;
13 |
--------------------------------------------------------------------------------
/app/schemas/token.py:
--------------------------------------------------------------------------------
1 | from pydantic import ConfigDict
2 |
3 | from app.schemas.core import BaseSchema
4 |
5 |
6 | class Token(BaseSchema):
7 | model_config = ConfigDict(alias_generator=None, populate_by_name=False, from_attributes=True)
8 | access_token: str
9 | token_type: str
10 |
11 |
12 | class TokenPayload(BaseSchema):
13 | sub: str | None
14 |
--------------------------------------------------------------------------------
/docker-compose.ecs.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | web:
5 | image: 589800662540.dkr.ecr.us-west-1.amazonaws.com/fastapi-sample-backend:latest
6 | build:
7 | context: ./backend
8 | dockerfile: Dockerfile
9 | ports:
10 | - 80:80
11 | volumes:
12 | - ./backend:/backend
13 | restart: always
14 | tty: true
15 |
--------------------------------------------------------------------------------
/.docker/docker-compose.ecs.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | web:
5 | image: 589800662540.dkr.ecr.us-west-1.amazonaws.com/fastapi-sample-backend:latest
6 | build:
7 | context: ../
8 | dockerfile: Dockerfile
9 | ports:
10 | - 80:80
11 | # volumes:
12 | # - ./backend:/backend
13 | restart: always
14 | tty: true
15 |
--------------------------------------------------------------------------------
/app/core/utils.py:
--------------------------------------------------------------------------------
1 | import socket
2 |
3 | import ulid
4 | from fastapi import Request
5 |
6 |
7 | def get_ulid() -> str:
8 | return ulid.new().str
9 |
10 |
11 | def get_request_info(request: Request) -> str:
12 | return request.client.host
13 |
14 |
15 | def get_host_by_ip_address(ip_address: str) -> str:
16 | return socket.gethostbyaddr(ip_address)[0]
17 |
--------------------------------------------------------------------------------
/elasticsearch/logstash/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM logstash:8.1.3
2 |
3 | USER root
4 | RUN apt-get update && apt-get install -y gcc libmariadb-dev curl
5 |
6 | USER logstash
7 | RUN rm -rf /usr/share/logstash/pipeline
8 | COPY ./pipeline pipeline
9 | RUN curl -o mysql-connector-java-8.0.29.jar -L https://repo1.maven.org/maven2/mysql/mysql-connector-java/8.0.29/mysql-connector-java-8.0.29.jar
10 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "FastAPI Sample",
6 | "type": "python",
7 | "request": "launch",
8 | "module": "uvicorn",
9 | "args": ["app.main:app", "--host", "0.0.0.0", "--port", "81", "--reload", "--log-config", "./app/logger_config.yaml"],
10 | "jinja": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.linting.enabled": true,
3 | "python.linting.pylintEnabled": false,
4 | // "python.linting.flake8Enabled": true,
5 | "python.linting.mypyEnabled": true,
6 | "python.formatting.provider": null,
7 | "python.linting.lintOnSave": true,
8 | "editor.formatOnSave": true,
9 | "editor.codeActionsOnSave": {
10 | "source.fixAll": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/frontend_sample/src/pages/todos/edit.tsx:
--------------------------------------------------------------------------------
1 | import TodoUpdateTemplate from "components/templates/todos/TodoUpdateTemplate/TodoUpdateTemplate";
2 | import { NextPage } from "next";
3 | // import TodoUpdateTemplate from "components/templates/todos/TodoUpdateTemplate/TodosUpdateTemplate";
4 |
5 | const TodoUpdatePage: NextPage = () => {
6 | return (
7 | <>
8 |
9 | >
10 | );
11 | };
12 |
13 | export default TodoUpdatePage;
14 |
--------------------------------------------------------------------------------
/app/exceptions/exception_handlers.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 | from urllib.request import Request
3 |
4 | from fastapi.responses import PlainTextResponse
5 |
6 | from app.core.logger import get_logger
7 |
8 | logger = get_logger(__name__)
9 |
10 |
11 | async def http_exception_handler(request: Request, exc: Any) -> PlainTextResponse: # noqa: ARG001
12 | """HTTPリクエストに起因したExceptionエラー発生時のフック処理."""
13 | logger.exception(str(exc))
14 | return PlainTextResponse("Server Error: " + str(exc), status_code=500)
15 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/index.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | /**
4 | * FastAPI Sample
5 | * Apps link
/docs
/admin/docs
/other/docs
6 | *
7 | * The version of the OpenAPI document: 0.0.1
8 | *
9 | *
10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
11 | * https://openapi-generator.tech
12 | * Do not edit the class manually.
13 | */
14 |
15 | export * from "./api";
16 | export * from "./configuration";
17 |
--------------------------------------------------------------------------------
/frontend_sample/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
7 | }
8 |
9 | a {
10 | color: inherit;
11 | text-decoration: none;
12 | }
13 |
14 | * {
15 | box-sizing: border-box;
16 | }
17 |
18 | @media (prefers-color-scheme: dark) {
19 | html {
20 | color-scheme: dark;
21 | }
22 | body {
23 | color: white;
24 | background: black;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/client.ts:
--------------------------------------------------------------------------------
1 | import { API_HOST } from "config";
2 | import { TodosApi, UsersApi } from "./api";
3 | import { Configuration } from "./configuration";
4 |
5 | const authConfig = new Configuration({
6 | basePath: API_HOST,
7 | baseOptions: { withCredentials: true },
8 | });
9 | const nonAuthConfig = new Configuration({
10 | basePath: API_HOST,
11 | baseOptions: { withCredentials: false },
12 | });
13 |
14 | export const UsersApiClient = new UsersApi(nonAuthConfig);
15 | export const TodosApiClient = new TodosApi(nonAuthConfig);
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.11-bookworm
2 |
3 | ENV LANG C.UTF-8
4 | ENV TZ UTC
5 | ENV PYTHONUNBUFFERED 1
6 | ENV PYTHONPATH /backend/app:/backend
7 | ENV TERM xterm-256color
8 |
9 | RUN apt-get update && apt-get install -y \
10 | git \
11 | gcc \
12 | libmariadb-dev \
13 | curl
14 |
15 | WORKDIR /backend
16 | COPY ./requirements.lock /backend/
17 |
18 | # ryeがrequirements.lockを常に最新化していることを前提とした暫定的な対応
19 | RUN sed '/-e/d' requirements.lock > requirements.txt
20 | RUN pip install -r requirements.txt
21 |
22 | COPY . /backend/
23 |
--------------------------------------------------------------------------------
/app/schemas/__init__.py:
--------------------------------------------------------------------------------
1 | from .core import BaseSchema, PagingMeta, PagingQueryIn, SortQueryIn
2 | from .language_analyzer import AnalyzedLanguage, AnalyzedlanguageToken
3 | from .request_info import RequestInfoResponse
4 | from .tag import TagCreate, TagResponse, TagsPagedResponse, TagUpdate
5 | from .todo import (
6 | TodoCreate,
7 | TodoResponse,
8 | TodoSortQueryIn,
9 | TodosPagedResponse,
10 | TodoUpdate,
11 | )
12 | from .token import Token, TokenPayload
13 | from .user import UserCreate, UserResponse, UsersPagedResponse, UserUpdate
14 |
--------------------------------------------------------------------------------
/frontend_sample/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/docs/docs/index.md:
--------------------------------------------------------------------------------
1 | # Welcome to MkDocs
2 |
3 | For full documentation visit [mkdocs.org](https://www.mkdocs.org).
4 |
5 | ## Commands
6 |
7 | * `mkdocs new [dir-name]` - Create a new project.
8 | * `mkdocs serve` - Start the live-reloading docs server.
9 | * `mkdocs build` - Build the documentation site.
10 | * `mkdocs -h` - Print help message and exit.
11 |
12 | ## Project layout
13 |
14 | mkdocs.yml # The configuration file.
15 | docs/
16 | index.md # The documentation homepage.
17 | ... # Other markdown pages, images and other files.
18 |
--------------------------------------------------------------------------------
/frontend_sample/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "../styles/globals.css";
2 | import type { AppProps } from "next/app";
3 | import { QueryClient, QueryClientProvider } from "react-query";
4 |
5 | const queryClient = new QueryClient({
6 | defaultOptions: {
7 | queries: {
8 | retry: false,
9 | refetchOnWindowFocus: false,
10 | },
11 | },
12 | });
13 |
14 | export default function App({ Component, pageProps }: AppProps) {
15 | return (
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/app/commands/user_creation.py:
--------------------------------------------------------------------------------
1 | import fire
2 |
3 | from app import crud, models, schemas
4 | from app.core.database import get_db
5 |
6 | from . import __set_base_path__ # noqa
7 |
8 |
9 | def create_user(email: str, full_name: str, password: str) -> models.User:
10 | db = next(get_db())
11 | user = crud.user.create(
12 | db,
13 | obj_in=schemas.UserCreate(full_name=full_name, email=email, password=password),
14 | )
15 | print(user.to_dict())
16 |
17 | return user
18 |
19 |
20 | if __name__ == "__main__":
21 | fire.Fire(create_user)
22 |
--------------------------------------------------------------------------------
/app/models/tags.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, String
2 | from sqlalchemy.orm import Mapped, relationship
3 |
4 | from app.models.base import Base, ModelBaseMixin
5 |
6 |
7 | class Tag(ModelBaseMixin, Base):
8 | __tablename__ = "tags"
9 | mysql_charset = ("utf8mb4",)
10 | mysql_collate = "utf8mb4_unicode_ci"
11 |
12 | name: Mapped[str] = Column(String(100), unique=True, index=True)
13 |
14 | todos: Mapped[list] = relationship(
15 | "Todo",
16 | secondary="todos_tags",
17 | back_populates="tags",
18 | lazy="joined",
19 | )
20 |
--------------------------------------------------------------------------------
/alembic/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 | ${imports if imports else ""}
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = ${repr(up_revision)}
14 | down_revision = ${repr(down_revision)}
15 | branch_labels = ${repr(branch_labels)}
16 | depends_on = ${repr(depends_on)}
17 |
18 |
19 | def upgrade():
20 | ${upgrades if upgrades else "pass"}
21 |
22 |
23 | def downgrade():
24 | ${downgrades if downgrades else "pass"}
25 |
--------------------------------------------------------------------------------
/docs/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: FastAPI Sample Docs
2 | nav:
3 | - Home: index.md
4 | - Install: install.md
5 | - Demos: demos.md
6 | - Features: features.md
7 | - Settings: settings.md
8 | - Logging: logging.md
9 | - ErrorExceptions: errors.md
10 | - Testing: testing.md
11 | - Migration: migration.md
12 | - CI/CD: ci-cd.md
13 | - PackageManagement: package-management.md
14 | - CRUD: crud.md
15 | - Models: models.md
16 | - Endpoints: endpoints.md
17 | - Schemas: schemas.md
18 | - PermissionScopes: permission-scopes.md
19 | - Docker: docker.md
20 | - Deployment: deployment.md
21 |
--------------------------------------------------------------------------------
/frontend_sample/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "my-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@types/node": "18.11.10",
13 | "@types/react": "18.0.26",
14 | "@types/react-dom": "18.0.9",
15 | "axios": "^1.2.0",
16 | "eslint": "8.29.0",
17 | "eslint-config-next": "13.0.6",
18 | "next": "13.0.6",
19 | "react": "18.2.0",
20 | "react-dom": "18.2.0",
21 | "react-query": "^3.39.2",
22 | "typescript": "4.9.3"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/frontend_sample/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": "src"
18 | },
19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/app/schemas/tag.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from pydantic import ConfigDict
4 |
5 | from app.schemas.core import BaseSchema, PagingMeta
6 |
7 |
8 | class TagBase(BaseSchema):
9 | name: str | None
10 |
11 |
12 | class TagResponse(TagBase):
13 | model_config = ConfigDict(from_attributes=True)
14 | id: str
15 | created_at: datetime.datetime | None
16 | updated_at: datetime.datetime | None
17 | deleted_at: datetime.datetime | None
18 |
19 |
20 | class TagCreate(TagBase):
21 | name: str
22 |
23 |
24 | class TagUpdate(TagBase):
25 | pass
26 |
27 |
28 | class TagsPagedResponse(BaseSchema):
29 | data: list[TagResponse] | None
30 | meta: PagingMeta | None
31 |
--------------------------------------------------------------------------------
/app/schemas/user.py:
--------------------------------------------------------------------------------
1 | from pydantic import ConfigDict, EmailStr
2 |
3 | from app.schemas.core import BaseSchema, PagingMeta
4 |
5 |
6 | class UserBase(BaseSchema):
7 | full_name: str | None = None
8 |
9 |
10 | class UserCreate(UserBase):
11 | email: EmailStr
12 | password: str
13 |
14 |
15 | # Properties to receive via API on update
16 | class UserUpdate(UserBase):
17 | password: str | None = None
18 |
19 |
20 | class UserResponse(UserBase):
21 | model_config = ConfigDict(from_attributes=True)
22 | id: str
23 | email: EmailStr
24 | email_verified: bool
25 |
26 |
27 | class UsersPagedResponse(BaseSchema):
28 | data: list[UserResponse] | None
29 | meta: PagingMeta | None
30 |
--------------------------------------------------------------------------------
/app/schemas/language_analyzer.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, Field
2 |
3 |
4 | class AnalyzedlanguageToken(BaseModel):
5 | surface: str = Field(..., description="表層形式(入力文字のまま)")
6 | dictionaly_form: str = Field(..., description="辞書形式")
7 | reading_form: str = Field(..., description="読みカナ")
8 | normalized_form: str = Field(..., description="正規化済の形式")
9 | part_of_speech: tuple[str] = Field(..., description="品詞")
10 | begin_pos: int = Field(..., description="開始文字番号")
11 | end_pos: int = Field(..., description="終了文字番号")
12 |
13 |
14 | class AnalyzedLanguage(BaseModel):
15 | raw_text: str
16 | tokens: list[AnalyzedlanguageToken] = []
17 | excluded_tokens: list[AnalyzedlanguageToken] = []
18 | during_time: float
19 |
--------------------------------------------------------------------------------
/app/models/todos_tags.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import ForeignKey, Integer, UniqueConstraint
2 | from sqlalchemy.orm import Mapped, mapped_column
3 |
4 | from app.models.base import Base, ModelBaseMixinWithoutDeletedAt
5 |
6 |
7 | class TodoTag(ModelBaseMixinWithoutDeletedAt, Base):
8 | __tablename__ = "todos_tags"
9 | mysql_charset = ("utf8mb4",)
10 | mysql_collate = "utf8mb4_unicode_ci"
11 | __table_args__ = (UniqueConstraint("todo_id", "tag_id", name="ix_todos_tags_todo_id_tag_id"),)
12 |
13 | id: Mapped[int] = mapped_column(Integer, autoincrement=True, primary_key=True)
14 | todo_id: Mapped[str] = mapped_column(ForeignKey("todos.id"), index=True)
15 | tag_id: Mapped[str] = mapped_column(ForeignKey("tags.id"), index=True)
16 |
--------------------------------------------------------------------------------
/tests/todos/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 |
5 | import pytest_asyncio
6 | from sqlalchemy.orm import Session
7 |
8 | from app import models
9 |
10 |
11 | @pytest_asyncio.fixture
12 | async def data_set(db: Session) -> None:
13 | await insert_todos(db)
14 |
15 |
16 | async def insert_todos(db: Session) -> None:
17 | now = datetime.datetime.now(tz=datetime.timezone.utc)
18 | data = [
19 | models.Todo(
20 | id=str(i),
21 | title=f"test-title-{i}",
22 | description=f"test-description-{i}",
23 | created_at=now - datetime.timedelta(days=i),
24 | )
25 | for i in range(1, 25)
26 | ]
27 | db.add_all(data)
28 | await db.commit()
29 |
--------------------------------------------------------------------------------
/seeder/seeds_json/todos.json:
--------------------------------------------------------------------------------
1 | {
2 | "model": "app.models.Todo",
3 | "data": [
4 | {
5 | "title": "TestTodo1",
6 | "description": "TestDescription1"
7 | },
8 | {
9 | "title": "TestTodo2",
10 | "description": "TestDescription2"
11 | },
12 | {
13 | "title": "TestTodo3",
14 | "description": "TestDescription3"
15 | },
16 | {
17 | "title": "TestTodo4",
18 | "description": "TestDescription4"
19 | },
20 | {
21 | "title": "TestTodo5",
22 | "description": "TestDescription5"
23 | },
24 | {
25 | "title": "TestTodo6",
26 | "description": "TestDescription6"
27 | }
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/app/models/todos.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from sqlalchemy import DateTime, String, Text
4 | from sqlalchemy.orm import Mapped, mapped_column, relationship
5 |
6 | from app.models.base import Base, ModelBaseMixin
7 |
8 |
9 | class Todo(ModelBaseMixin, Base):
10 | __tablename__ = "todos"
11 | mysql_charset = ("utf8mb4",)
12 | mysql_collate = "utf8mb4_unicode_ci"
13 |
14 | title: Mapped[str] = mapped_column(String(100), index=True)
15 | description: Mapped[str] = mapped_column(Text, nullable=True)
16 | completed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
17 |
18 | tags: Mapped[list] = relationship(
19 | "Tag",
20 | secondary="todos_tags",
21 | back_populates="todos",
22 | lazy="joined",
23 | )
24 |
--------------------------------------------------------------------------------
/seeder/seeds_json/users.json:
--------------------------------------------------------------------------------
1 | {
2 | "model": "app.models.User",
3 | "data": [
4 | {
5 | "id": "1",
6 | "email": "test-user1@example.com",
7 | "hashed_password": "$2b$12$mV0czmMSlSBR66ZqiJZ9HuE9z0VKhMqwdH.t2a3TZeXhwtWCuU/gi",
8 | "full_name": "user1"
9 | },
10 | {
11 | "id": "2",
12 | "email": "test-user2@example.com",
13 | "hashed_password": "$2b$12$mV0czmMSlSBR66ZqiJZ9HuE9z0VKhMqwdH.t2a3TZeXhwtWCuU/gi",
14 | "full_name": "user2"
15 | },
16 | {
17 | "id": "3",
18 | "email": "test-user3@example.com",
19 | "hashed_password": "$2b$12$mV0czmMSlSBR66ZqiJZ9HuE9z0VKhMqwdH.t2a3TZeXhwtWCuU/gi",
20 | "full_name": "user3"
21 | }
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/app/models/users.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Boolean, String, Text
2 | from sqlalchemy.orm import Mapped, mapped_column
3 |
4 | from app.models.base import Base, ModelBaseMixin
5 |
6 |
7 | class User(ModelBaseMixin, Base):
8 | __tablename__ = "users"
9 | mysql_charset = ("utf8mb4",)
10 | mysql_collate = "utf8mb4_unicode_ci"
11 |
12 | full_name: Mapped[str] = mapped_column(String(64), index=True)
13 | email: Mapped[str] = mapped_column(
14 | String(200),
15 | unique=True,
16 | index=True,
17 | nullable=False,
18 | )
19 | email_verified: Mapped[bool] = mapped_column(
20 | Boolean,
21 | nullable=False,
22 | server_default="0",
23 | )
24 | hashed_password: Mapped[str] = mapped_column(Text, nullable=False)
25 | scopes: Mapped[str] = mapped_column(Text, nullable=True)
26 |
--------------------------------------------------------------------------------
/frontend_sample/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Image from "next/image";
3 | import Link from "next/link";
4 | import { useRouter } from "next/router";
5 | import styles from "../styles/Home.module.css";
6 |
7 | export default function Home() {
8 | const router = useRouter();
9 | return (
10 |
11 |
12 |
Fastapi Sample Frontend
13 |
14 |
15 |
Fastapi Sample Frontend
16 |
17 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/api/endpoints/tasks.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import time
3 |
4 | from fastapi import APIRouter, BackgroundTasks
5 |
6 | from app.core.logger import get_logger
7 |
8 | logger = get_logger(__name__)
9 |
10 | router = APIRouter()
11 |
12 |
13 | def long_process_thread(id: str) -> None:
14 | for i in range(100):
15 | logger.info(f"[long_process_thread(id={id})] {i+1}sec")
16 | time.sleep(1)
17 |
18 |
19 | async def long_process_async(id: str) -> None:
20 | for i in range(100):
21 | logger.info(f"[long_process_asyncio(id={id})] {i+1}sec")
22 | await asyncio.sleep(1)
23 |
24 |
25 | @router.post("/long-process/thread")
26 | def exec_long_process_thread(
27 | id: str,
28 | background_tasks: BackgroundTasks,
29 | ) -> None:
30 | background_tasks.add_task(long_process_thread, id)
31 |
32 |
33 | @router.post("/long-process/async")
34 | async def exec_long_process_async(
35 | id: str,
36 | background_tasks: BackgroundTasks,
37 | ) -> None:
38 | background_tasks.add_task(long_process_async, id)
39 |
--------------------------------------------------------------------------------
/app/exceptions/core.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from fastapi import HTTPException, status
4 |
5 |
6 | class APIException(HTTPException):
7 | """API例外."""
8 |
9 | default_status_code = status.HTTP_400_BAD_REQUEST
10 |
11 | def __init__(
12 | self,
13 | error: Any,
14 | status_code: int = default_status_code,
15 | headers: dict[str, Any] | None = None,
16 | ) -> None:
17 | self.headers = headers
18 | try:
19 | error_obj = error()
20 | except Exception:
21 | error_obj = error
22 |
23 | try:
24 | message = error_obj.text.format(error_obj.param)
25 | except Exception:
26 | message = error_obj.text
27 |
28 | try:
29 | self.status_code = error_obj.status_code
30 | except Exception:
31 | self.status_code = status_code
32 |
33 | self.detail = {"error_code": str(error_obj), "error_msg": message}
34 | print(self.detail)
35 | super().__init__(self.status_code, self.detail)
36 |
--------------------------------------------------------------------------------
/frontend_sample/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/Dockerfile.lambda:
--------------------------------------------------------------------------------
1 | ARG FUNCTION_DIR="/function"
2 | FROM python:3.11-bookworm
3 |
4 | ENV LANG C.UTF-8
5 | ENV TZ UTC
6 | ENV PYTHONUNBUFFERED 1
7 | ENV PYTHONPATH ${FUNCTION_DIR}/backend
8 | ENV TERM xterm-256color
9 | # EXPOSE 80
10 |
11 | RUN apt-get update && apt-get install -y \
12 | git \
13 | gcc \
14 | libmariadb-dev \
15 | curl
16 | RUN curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python && \
17 | cd /usr/local/bin && \
18 | ln -s /opt/poetry/bin/poetry && \
19 | poetry config virtualenvs.create false
20 |
21 | # Include global arg in this stage of the build
22 | ARG FUNCTION_DIR
23 | # Create function directory
24 | RUN mkdir -p ${FUNCTION_DIR}
25 | WORKDIR ${FUNCTION_DIR}/backend
26 | COPY ./pyproject.toml ./poetry.lock ${FUNCTION_DIR}/backend/
27 |
28 | RUN poetry install --no-root
29 | RUN pip install awslambdaric
30 |
31 | COPY . ${FUNCTION_DIR}/backend/
32 |
33 | ENTRYPOINT [ "/usr/local/bin/python", "-m", "awslambdaric" ]
34 | CMD ["app/main.handler"]
35 | # CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80", "--reload"]
36 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 takashi.yoneya
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/.openapi-generator-ignore:
--------------------------------------------------------------------------------
1 | # OpenAPI Generator Ignore
2 | # Generated by openapi-generator https://github.com/openapitools/openapi-generator
3 |
4 | # Use this file to prevent files from being overwritten by the generator.
5 | # The patterns follow closely to .gitignore or .dockerignore.
6 |
7 | # As an example, the C# client generator defines ApiClient.cs.
8 | # You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9 | #ApiClient.cs
10 |
11 | # You can match any string of characters against a directory, file or extension with a single asterisk (*):
12 | #foo/*/qux
13 | # The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
14 |
15 | # You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16 | #foo/**/qux
17 | # This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
18 |
19 | # You can also negate patterns with an exclamation (!).
20 | # For example, you can ignore all files in a docs folder with the file extension .md:
21 | #docs/*.md
22 | # Then explicitly reverse the ignore rule for a single file:
23 | #!docs/README.md
24 |
--------------------------------------------------------------------------------
/elasticsearch/logstash/pipeline/main.conf:
--------------------------------------------------------------------------------
1 | input {
2 | jdbc {
3 | jdbc_driver_library => "/usr/share/logstash/mysql-connector-java-8.0.29.jar"
4 | jdbc_driver_class => "com.mysql.cj.jdbc.Driver"
5 | jdbc_connection_string => "jdbc:mysql://db:3306/docker"
6 | jdbc_default_timezone => "Asia/Tokyo"
7 | jdbc_user => "docker"
8 | jdbc_password => "docker"
9 | jdbc_default_timezone => "Asia/Tokyo"
10 | statement => "SELECT * FROM jobs where updated_at > :sql_last_value"
11 | tracking_column => "updated_at"
12 | schedule => "*/1 * * * *"
13 | }
14 | }
15 |
16 | filter {
17 | mutate {
18 | copy => { "id" => "[@metadata][_id]"}
19 | remove_field => ["@version", "@timestamp"]
20 | }
21 | if [deleted_at] {
22 | mutate {
23 | add_field => {"[@metadata][action]" => "delete"}
24 | }
25 | } else {
26 | mutate {
27 | add_field => {"[@metadata][action]" => "index"}
28 | }
29 | }
30 | }
31 |
32 | output {
33 | elasticsearch {
34 | hosts => ["http://es01:9200"]
35 | index => "jobs"
36 | action => "%{[@metadata][action]}"
37 | document_id => "%{[@metadata][_id]}"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/pydantic_performances/test_performances.py:
--------------------------------------------------------------------------------
1 | import time
2 | from datetime import datetime, timezone
3 |
4 | import pytest
5 | from pydantic import BaseModel
6 |
7 |
8 | class Tag(BaseModel):
9 | id: int
10 | tag: str
11 |
12 |
13 | class Data(BaseModel):
14 | name: str
15 | age: int
16 | address: str
17 | point: float
18 | tags: list[Tag]
19 | created_at: datetime
20 | updated_at: datetime
21 |
22 |
23 | @pytest.mark.skip(reason="pydantic v2のパフォーマンス検証用のため、通常のテストでは使用しない")
24 | def test_check_performances() -> None:
25 | """pydantic v2のパフォーマンス検証用"""
26 | start = time.time()
27 | for i in range(1000000):
28 | data = Data(
29 | name=f"John{i}",
30 | age=20,
31 | address="New York",
32 | point=1.23,
33 | tags=[
34 | Tag(id=1, tag="tag1"),
35 | Tag(id=2, tag="tag2"),
36 | Tag(id=3, tag="tag3"),
37 | ],
38 | created_at=datetime.now(tz=timezone.utc),
39 | updated_at=datetime.now(tz=timezone.utc),
40 | )
41 | data.model_dump_json()
42 |
43 | print(f"Time taken: {time.time() - start}")
44 |
--------------------------------------------------------------------------------
/app/api/endpoints/develop.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Request
2 |
3 | from app import schemas
4 | from app.core import utils
5 | from app.core.logger import get_logger
6 | from app.exceptions.core import APIException
7 | from app.exceptions.error_messages import ErrorMessage
8 | from app.schemas.core import BaseSchema
9 |
10 | logger = get_logger(__name__)
11 |
12 | router = APIRouter()
13 |
14 |
15 | @router.get("/error")
16 | def exec_error() -> None:
17 | logger.error("debug test")
18 | print(1 / 0)
19 | raise APIException(ErrorMessage.NOT_FOUND("デバックテストID"))
20 |
21 |
22 | @router.get("/error2")
23 | def exec_error2() -> None:
24 | raise APIException(ErrorMessage.INTERNAL_SERVER_ERROR)
25 |
26 |
27 | class RequestInfoResponse(BaseSchema):
28 | ip_address: str | None
29 | host: str | None
30 |
31 | class Config:
32 | orm_mode = True
33 |
34 |
35 | @router.get("/request-info")
36 | def get_request_info(request: Request) -> schemas.RequestInfoResponse:
37 | ip_address = utils.get_request_info(request)
38 | host = utils.get_host_by_ip_address(ip_address)
39 |
40 | return schemas.RequestInfoResponse(ip_address=ip_address, host=host)
41 |
--------------------------------------------------------------------------------
/app/logger_config.yaml:
--------------------------------------------------------------------------------
1 | version: 1
2 | disable_existing_loggers: false
3 |
4 | formatters:
5 | json:
6 | format: "%(asctime)s %(name)s %(levelname)s %(message)s %(filename)s %(module)s %(funcName)s %(lineno)d"
7 | class: pythonjsonlogger.jsonlogger.JsonFormatter
8 | normal:
9 | format: "%(asctime)s %(name)s %(levelname)s %(message)s %(filename)s %(module)s %(funcName)s %(lineno)d"
10 |
11 | handlers:
12 | console:
13 | class: logging.StreamHandler
14 | level: INFO
15 | formatter: normal # json形式にしたい場合はjsonと記述する
16 | stream: ext://sys.stdout
17 |
18 | loggers:
19 | backend:
20 | level: INFO
21 | handlers: [console]
22 | propagate: false
23 |
24 | # gunicorn.error:
25 | # level: DEBUG
26 | # handlers: [console]
27 | # propagate: false
28 |
29 | uvicorn.access:
30 | level: INFO
31 | handlers: [console]
32 | propagate: false
33 |
34 | # sqlalchemy.engine:
35 | # level: DEBUG
36 | # handlers: [console]
37 | # propagate: false
38 |
39 | alembic.runtime.migration:
40 | level: INFO
41 | handlers: [console]
42 | propagate: false
43 |
44 | root:
45 | level: INFO
46 | handlers: [console]
47 |
--------------------------------------------------------------------------------
/app/crud/tag.py:
--------------------------------------------------------------------------------
1 | from fastapi.encoders import jsonable_encoder
2 | from sqlalchemy.dialects.mysql import insert
3 | from sqlalchemy.orm import Session
4 |
5 | from app import models, schemas
6 |
7 | from .base import CRUDBase
8 |
9 |
10 | class CRUDTag(
11 | CRUDBase[
12 | models.Tag,
13 | schemas.TagResponse,
14 | schemas.TagCreate,
15 | schemas.TagUpdate,
16 | schemas.TagsPagedResponse,
17 | ],
18 | ):
19 | def upsert_tags(
20 | self,
21 | db: Session,
22 | tags_in: list[schemas.TagCreate],
23 | ) -> list[models.Tag]:
24 | tags_in_list = jsonable_encoder(tags_in)
25 | insert_stmt = insert(models.Tag).values(tags_in_list)
26 | insert_stmt = insert_stmt.on_duplicate_key_update(
27 | name=insert_stmt.inserted.name,
28 | )
29 | db.execute(insert_stmt)
30 |
31 | tag_names = (x.name for x in tags_in)
32 | tags = db.query(models.Tag).filter(models.Tag.name.in_(tag_names)).all()
33 |
34 | return tags
35 |
36 |
37 | tag = CRUDTag(
38 | models.Tag,
39 | response_schema_class=schemas.TagResponse,
40 | list_response_class=schemas.TagsPagedResponse,
41 | )
42 |
--------------------------------------------------------------------------------
/app/schemas/todo.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from enum import Enum
3 |
4 | from fastapi import Query
5 | from pydantic import ConfigDict
6 |
7 | from app import schemas
8 | from app.schemas.core import BaseSchema, PagingMeta
9 | from app.schemas.tag import TagResponse
10 |
11 |
12 | class TodoBase(BaseSchema):
13 | title: str | None = None
14 | description: str | None = None
15 | completed_at: datetime.datetime | None = None
16 |
17 |
18 | class TodoResponse(TodoBase):
19 | id: str
20 | tags: list[TagResponse] | None = []
21 | created_at: datetime.datetime | None = None
22 | updated_at: datetime.datetime | None = None
23 |
24 | model_config = ConfigDict(from_attributes=True)
25 |
26 |
27 | class TodoCreate(TodoBase):
28 | title: str
29 | description: str | None = None
30 |
31 |
32 | class TodoUpdate(TodoBase):
33 | pass
34 |
35 |
36 | class TodosPagedResponse(BaseSchema):
37 | data: list[TodoResponse] | None = []
38 | meta: PagingMeta | None = None
39 |
40 |
41 | class TodoSortFieldEnum(Enum):
42 | created_at = "created_at"
43 | title = "title"
44 |
45 |
46 | class TodoSortQueryIn(schemas.SortQueryIn):
47 | sort_field: TodoSortFieldEnum | None = Query(TodoSortFieldEnum.created_at)
48 |
--------------------------------------------------------------------------------
/app/crud_v2/tag.py:
--------------------------------------------------------------------------------
1 | from fastapi.encoders import jsonable_encoder
2 | from sqlalchemy.dialects.mysql import insert
3 | from sqlalchemy.orm import Session
4 | from sqlalchemy.sql import select
5 |
6 | from app import models, schemas
7 |
8 | from .base import CRUDV2Base
9 |
10 |
11 | class CRUDTag(
12 | CRUDV2Base[
13 | models.Tag,
14 | schemas.TagResponse,
15 | schemas.TagCreate,
16 | schemas.TagUpdate,
17 | schemas.TagsPagedResponse,
18 | ],
19 | ):
20 | def upsert_tags(
21 | self,
22 | db: Session,
23 | tags_in: list[schemas.TagCreate],
24 | ) -> list[models.Tag]:
25 | tags_in_list = jsonable_encoder(tags_in)
26 | insert_stmt = insert(models.Tag).values(tags_in_list)
27 | insert_stmt = insert_stmt.on_duplicate_key_update(
28 | name=insert_stmt.inserted.name,
29 | )
30 | db.execute(insert_stmt)
31 |
32 | tag_names = (x.name for x in tags_in)
33 | stmt = select(models.Tag).where(models.Tag.name.in_(tag_names))
34 | tags = db.execute(stmt).scalars().all()
35 |
36 | return tags
37 |
38 |
39 | tag = CRUDTag(
40 | models.Tag,
41 | response_schema_class=schemas.TagResponse,
42 | list_response_class=schemas.TagsPagedResponse,
43 | )
44 |
--------------------------------------------------------------------------------
/app/api/endpoints/auth.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from fastapi import APIRouter, Depends
4 | from fastapi.security import OAuth2PasswordRequestForm
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 |
7 | from app import crud_v2, schemas
8 | from app.core import auth
9 | from app.core.config import settings
10 | from app.core.database import get_async_db
11 | from app.exceptions.core import APIException
12 | from app.exceptions.error_messages import ErrorMessage
13 |
14 | router = APIRouter()
15 |
16 |
17 | @router.post("/login")
18 | async def login_access_token(
19 | db: AsyncSession = Depends(get_async_db),
20 | form_data: OAuth2PasswordRequestForm = Depends(),
21 | ) -> schemas.Token:
22 | """OAuth2 compatible token login, get an access token for future requests."""
23 | user = await crud_v2.user.authenticate(
24 | db,
25 | email=form_data.username,
26 | password=form_data.password,
27 | )
28 | if not user:
29 | raise APIException(ErrorMessage.FAILURE_LOGIN)
30 |
31 | access_token_expires = datetime.timedelta(
32 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
33 | )
34 | access_token = auth.create_access_token(user.id, expires_delta=access_token_expires)
35 | return schemas.Token(
36 | access_token=access_token,
37 | token_type="bearer",
38 | )
39 |
--------------------------------------------------------------------------------
/app/app_manager.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 |
3 |
4 | class FastAPIAppManager:
5 | def __init__(self, root_app: FastAPI) -> None:
6 | self.app_path_list: list[str] = [""]
7 | self.root_app: FastAPI = root_app
8 | self.apps: list[FastAPI] = [root_app]
9 |
10 | def add_app(self, app: FastAPI, path: str) -> None:
11 | self.apps.append(app)
12 | if not path.startswith("/"):
13 | path = f"/{path}"
14 | else:
15 | path = path
16 | self.app_path_list.append(path)
17 | app.title = f"{self.root_app.title}({path})"
18 | app.version = self.root_app.version
19 | app.debug = self.root_app.debug
20 | self.root_app.mount(path=path, app=app)
21 |
22 | def setup_apps_docs_link(self) -> None:
23 | """他のAppへのリンクがopenapiに表示されるようにセットする"""
24 | for app, path in zip(self.apps, self.app_path_list, strict=True):
25 | app.description = self._make_app_docs_link_html(path)
26 |
27 | def _make_app_docs_link_html(self, current_path: str) -> str:
28 | # openapiの上部に表示する各Appへのリンクを生成する
29 | descriptions = [
30 | f"{path}/docs" if path != current_path else f"{path}/docs"
31 | for path in self.app_path_list
32 | ]
33 | descriptions.insert(0, "Apps link")
34 | return "
".join(descriptions)
35 |
--------------------------------------------------------------------------------
/mypy.ini:
--------------------------------------------------------------------------------
1 | [mypy]
2 | python_version = 3.11
3 | platform = linux
4 | plugins = pydantic.mypy,sqlalchemy.ext.mypy.plugin
5 |
6 | # Import discovery
7 | namespace_packages=True
8 | ignore_missing_imports = True
9 | # follow_imports=error
10 | # follow_imports_for_stubs=True
11 | no_site_packages = True
12 | no_silence_site_packages=True
13 |
14 | # Disallow dynamic typing
15 | # disallow_any_unimported=True
16 | # disallow_any_expr=True
17 | # disallow_any_decorated=True
18 | # disallow_any_explicit=True
19 | disallow_any_generics=True
20 | # disallow_subclassing_any=True
21 |
22 | # Untyped definitions and calls
23 | disallow_untyped_calls = True
24 | disallow_untyped_defs = True
25 | disallow_incomplete_defs = True
26 | check_untyped_defs = True
27 | # disallow_untyped_decorators = True
28 |
29 | # None and Optional handling
30 | no_implicit_optional = True
31 |
32 | # Configuring warnings
33 | warn_redundant_casts = True
34 | warn_unused_ignores = True
35 | # warn_return_any = True
36 | warn_unreachable = True
37 |
38 | # Miscellaneous strictness flags
39 | strict_equality = True
40 |
41 | # Configuring error messages
42 | show_error_context = True
43 | show_column_numbers = True
44 | show_error_codes = True
45 | # pretty = True
46 |
47 | # Miscellaneous
48 | warn_unused_configs = True
49 |
50 | [pydantic-mypy]
51 | init_forbid_extra = True
52 | init_typed = True
53 | warn_required_dynamic_aliases = True
54 | warn_untyped_fields = True
55 |
--------------------------------------------------------------------------------
/.aws/ecs-task-definition.json:
--------------------------------------------------------------------------------
1 | {
2 | "family": "fastapi-sample-backend",
3 | "networkMode": "awsvpc",
4 | "executionRoleArn": "arn:aws:iam::589800662540:role/fastapi-sample-backend-task-execution-role",
5 | "taskRoleArn": "arn:aws:iam::589800662540:role/fastapi-sample-backend-task-role",
6 | "containerDefinitions": [
7 | {
8 | "name": "fastapi-sample-backend",
9 | "image": "589800662540.dkr.ecr.us-west-1.amazonaws.com/fastapi-sample-backend:latest",
10 | "linuxParameters": {
11 | "initProcessEnabled": true
12 | },
13 | "logConfiguration": {
14 | "logDriver": "awslogs",
15 | "options": {
16 | "awslogs-group": "/aws/ecs/fastapi-sample-backend",
17 | "awslogs-region": "us-west-1",
18 | "awslogs-stream-prefix": "container-stdout"
19 | }
20 | },
21 | "command": [
22 | "uvicorn",
23 | "main:app",
24 | "--host",
25 | "0.0.0.0",
26 | "--port",
27 | "80"
28 | ],
29 | "portMappings": [
30 | {
31 | "hostPort": 80,
32 | "protocol": "tcp",
33 | "containerPort": 80
34 | }
35 | ]
36 | }
37 | ],
38 | "requiresCompatibilities": [
39 | "FARGATE"
40 | ],
41 | "cpu": "256",
42 | "memory": "512"
43 | }
44 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # コンテナ内に入る
2 | .PHONY: docker-run
3 | docker-run:
4 | @docker compose run --rm web bash
5 |
6 | # Docker内でファイルを作成・更新した場合に、ローカルで権限の問題になる場合の対応(ubuntuの場合)
7 | .PHONY: chown
8 | chown:
9 | @sudo chown -hR ${USER}:${USER} .
10 |
11 | # フロントエンド用のAPIClientを生成
12 | .PHONY: openapi-generator
13 | openapi-generator:
14 | @docker compose run --rm openapi-generator
15 |
16 | # 全てのファイルに対してpre-commitを実行
17 | .PHONY: pre-commit-all
18 | pre-commit-all:
19 | @pre-commit run --all-files
20 |
21 | # pre-commitで指定されているパッケージのバージョンを更新
22 | .PHONY: pre-commit-update
23 | pre-commit-update:
24 | @pre-commit autoupdate
25 |
26 | # pytestでテストを実行
27 | .PHONY: test
28 | test:
29 | @docker compose run --rm web bash -c "pytest tests/ --durations=5 -v"
30 |
31 | # マイグレーションファイルを作成
32 | # m: マイグレーションファイルの名前
33 | .PHONY: makemigrations
34 | makemigrations:
35 | @docker compose run --rm web bash -c "alembic revision --autogenerate -m ${m}"
36 |
37 | # DBのマイグレーション
38 | .PHONY: migrate
39 | migrate:
40 | @docker compose run --rm web bash -c "alembic upgrade heads"
41 |
42 | # Seedデータの投入
43 | .PHONY: seeder
44 | seeder:
45 | @docker compose run --rm web bash -c "python seeder/run.py import_seed"
46 |
47 | # 全てのテーブルの削除
48 | .PHONY: drop-all-tables
49 | drop-all-tables:
50 | @docker compose run --rm web bash -c "python seeder/run.py drop_all_tables"
51 |
52 | # dbの初期化
53 | .PHONY: init-db
54 | init-db:
55 | @make drop-all-tables
56 | @make migrate
57 | @make seeder
58 |
--------------------------------------------------------------------------------
/.vscode/database-client-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://release-1256208672.cos.ap-guangzhou.myqcloud.com/dbclient/schema.json",
3 | "database": {
4 | "1651840710641": {
5 | "key": "1651840710641",
6 | "host": "localhost",
7 | "port": "3307",
8 | "user": "docker",
9 | "authType": "default",
10 | "password": "docker",
11 | "encoding": "utf8",
12 | "useSSL": false,
13 | "useClearText": false,
14 | "usingSSH": false,
15 | "showHidden": true,
16 | "dbType": "MySQL",
17 | "encrypt": true,
18 | "hideSystemSchema": true,
19 | "readonly": false,
20 | "savePassword": "Forever",
21 | "sort": 10,
22 | "isCluster": false,
23 | "srv": false,
24 | "esAuth": "none",
25 | "global": false,
26 | "ssh": {
27 | "host": "",
28 | "privateKeyPath": "",
29 | "port": 22,
30 | "username": "root",
31 | "type": "password",
32 | "connectTimeout": 10000,
33 | "algorithms": {
34 | "cipher": []
35 | }
36 | },
37 | "connectionKey": "database",
38 | "name": "fastapi-db-local",
39 | "tooltip": "Host: localhost, Port: 3307"
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/frontend_sample/src/lib/hooks/api/todos.ts:
--------------------------------------------------------------------------------
1 | import type { UseGetResult, UsePostResult } from ".";
2 | import { useGet, usePost } from ".";
3 | import { ErrorResponse } from "types/api";
4 | import internal from "stream";
5 | import {
6 | TodoCreate,
7 | TodoResponse,
8 | TodosPagedResponse,
9 | TodoUpdate,
10 | } from "api_clients";
11 | import { TodosApiClient } from "api_clients/client";
12 |
13 | export const useGetTodoById = (id?: string): UseGetResult =>
14 | useGet([`/todos`, id], async () =>
15 | id ? TodosApiClient.getTodoById(id) : Promise.reject(),
16 | );
17 |
18 | type GetJobsProps = {
19 | q?: string;
20 | page?: number;
21 | perPage?: number;
22 | };
23 |
24 | export const useGetTodos = (
25 | props: GetJobsProps,
26 | ): UseGetResult => {
27 | const { q, page, perPage } = props;
28 | return useGet([`/todos`, q, page, perPage], async () =>
29 | TodosApiClient.getTodos(q, page, perPage),
30 | );
31 | };
32 |
33 | export const useCreateTodo = (): UsePostResult<
34 | TodoResponse,
35 | ErrorResponse,
36 | TodoCreate
37 | > =>
38 | usePost([`/todos`], async (request: TodoCreate) =>
39 | TodosApiClient.createTodo(request),
40 | );
41 |
42 | export const useUpdateTodo = (
43 | id?: string,
44 | ): UsePostResult => {
45 | return usePost([`/todos`, id], async (request: TodoUpdate) =>
46 | id ? TodosApiClient.updateTodo(id, request) : Promise.reject(),
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/requirements.lock:
--------------------------------------------------------------------------------
1 | # generated by rye
2 | # use `rye lock` or `rye sync` to update this lockfile
3 | #
4 | # last locked with the following flags:
5 | # pre: false
6 | # features: []
7 | # all-features: false
8 |
9 | -e file:.
10 | aiomysql==0.1.1
11 | alembic==1.11.1
12 | annotated-types==0.5.0
13 | anyio==3.7.0
14 | bcrypt==4.0.1
15 | certifi==2023.5.7
16 | click==8.1.3
17 | dnspython==2.3.0
18 | ecdsa==0.18.0
19 | email-validator==2.0.0.post2
20 | fastapi==0.100.0
21 | fastapi-debug-toolbar==0.5.0
22 | fire==0.5.0
23 | h11==0.14.0
24 | httpcore==0.17.2
25 | httpx==0.24.1
26 | humps==0.2.2
27 | idna==3.4
28 | iniconfig==2.0.0
29 | jinja2==3.1.2
30 | mako==1.2.4
31 | mangum==0.17.0
32 | markupsafe==2.1.2
33 | mirakuru==2.5.1
34 | mysql-connector-python==8.0.33
35 | mysqlclient==2.1.1
36 | packaging==23.1
37 | passlib==1.7.4
38 | pluggy==1.0.0
39 | port-for==0.6.3
40 | protobuf==3.20.3
41 | psutil==5.9.5
42 | pyasn1==0.5.0
43 | pydantic==2.0.2
44 | pydantic-core==2.1.2
45 | pydantic-extra-types==2.0.0
46 | pydantic-settings==2.0.0
47 | pyinstrument==4.5.1
48 | pymysql==1.0.3
49 | pytest==7.3.1
50 | pytest-asyncio==0.21.0
51 | pytest-mysql==2.4.2
52 | python-dotenv==1.0.0
53 | python-jose==3.3.0
54 | python-json-logger==2.0.7
55 | python-multipart==0.0.6
56 | pyyaml==6.0
57 | rsa==4.9
58 | sentry-sdk==1.24.0
59 | six==1.16.0
60 | sniffio==1.3.0
61 | sqlalchemy==2.0.15
62 | sqlalchemyseed==2.0.0
63 | sqlparse==0.4.4
64 | starlette==0.27.0
65 | termcolor==2.3.0
66 | toml==0.10.2
67 | typing-extensions==4.6.2
68 | ulid-py==1.1.0
69 | urllib3==1.26.16
70 | uvicorn==0.22.0
71 |
--------------------------------------------------------------------------------
/app/core/logger/logger.py:
--------------------------------------------------------------------------------
1 | import os
2 | from logging import Logger, getLogger
3 | from logging.config import dictConfig
4 |
5 | import yaml
6 |
7 |
8 | def init_logger(filepath: str) -> None:
9 | with open(filepath) as f:
10 | config = yaml.safe_load(f)
11 | dictConfig(config)
12 |
13 |
14 | def init_gunicorn_uvicorn_logger(filepath: str) -> None:
15 | import logging
16 |
17 | from fastapi.logger import logger as fastapi_logger
18 |
19 | if "gunicorn" in os.environ.get("SERVER_SOFTWARE", ""):
20 | """
21 | gunicornで起動した場合のみ、loggerを切り替える必要がある
22 | """
23 | init_logger(filepath)
24 | gunicorn_logger = logging.getLogger("gunicorn")
25 | log_level = gunicorn_logger.level
26 |
27 | root_logger = logging.getLogger()
28 | gunicorn_error_logger = logging.getLogger("gunicorn.error")
29 | uvicorn_access_logger = logging.getLogger("uvicorn.access")
30 |
31 | # Use gunicorn error handlers for root, uvicorn, and fastapi loggers
32 | root_logger.handlers = gunicorn_error_logger.handlers
33 | uvicorn_access_logger.handlers = gunicorn_error_logger.handlers
34 | fastapi_logger.handlers = gunicorn_error_logger.handlers
35 |
36 | # Pass on logging levels for root, uvicorn, and fastapi loggers
37 | root_logger.setLevel(log_level)
38 | uvicorn_access_logger.setLevel(log_level)
39 | fastapi_logger.setLevel(log_level)
40 |
41 |
42 | def get_logger(name: str) -> Logger:
43 | return getLogger(name)
44 |
--------------------------------------------------------------------------------
/requirements-dev.lock:
--------------------------------------------------------------------------------
1 | # generated by rye
2 | # use `rye lock` or `rye sync` to update this lockfile
3 | #
4 | # last locked with the following flags:
5 | # pre: false
6 | # features: []
7 | # all-features: false
8 |
9 | -e file:.
10 | aiomysql==0.1.1
11 | alembic==1.11.1
12 | annotated-types==0.5.0
13 | anyio==3.7.0
14 | bcrypt==4.0.1
15 | certifi==2023.5.7
16 | click==8.1.3
17 | dnspython==2.3.0
18 | ecdsa==0.18.0
19 | email-validator==2.0.0.post2
20 | fastapi==0.100.0
21 | fastapi-debug-toolbar==0.5.0
22 | fire==0.5.0
23 | h11==0.14.0
24 | httpcore==0.17.2
25 | httpx==0.24.1
26 | humps==0.2.2
27 | idna==3.4
28 | iniconfig==2.0.0
29 | jinja2==3.1.2
30 | mako==1.2.4
31 | mangum==0.17.0
32 | markupsafe==2.1.2
33 | mirakuru==2.5.1
34 | mysql-connector-python==8.0.33
35 | mysqlclient==2.1.1
36 | packaging==23.1
37 | passlib==1.7.4
38 | pluggy==1.0.0
39 | port-for==0.6.3
40 | protobuf==3.20.3
41 | psutil==5.9.5
42 | pyasn1==0.5.0
43 | pydantic==2.0.2
44 | pydantic-core==2.1.2
45 | pydantic-extra-types==2.0.0
46 | pydantic-settings==2.0.0
47 | pyinstrument==4.5.1
48 | pymysql==1.0.3
49 | pytest==7.3.1
50 | pytest-asyncio==0.21.0
51 | pytest-mysql==2.4.2
52 | python-dotenv==1.0.0
53 | python-jose==3.3.0
54 | python-json-logger==2.0.7
55 | python-multipart==0.0.6
56 | pyyaml==6.0
57 | rsa==4.9
58 | ruff==0.0.270
59 | sentry-sdk==1.24.0
60 | six==1.16.0
61 | sniffio==1.3.0
62 | sqlalchemy==2.0.15
63 | sqlalchemyseed==2.0.0
64 | sqlparse==0.4.4
65 | starlette==0.27.0
66 | termcolor==2.3.0
67 | toml==0.10.2
68 | typing-extensions==4.6.2
69 | ulid-py==1.1.0
70 | urllib3==1.26.16
71 | uvicorn==0.22.0
72 |
--------------------------------------------------------------------------------
/seeder/run.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from pathlib import Path
3 |
4 | import fire
5 | from sqlalchemy import text
6 | from sqlalchemy.orm import Session
7 | from sqlalchemyseed import Seeder, load_entities_from_json
8 |
9 | from app.core import database
10 | from app.core.config import settings
11 | from app.core.logger.logger import init_logger
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | # TODO: できれば、Seederの処理と連動させたい
17 | def truncate_tables(db: Session) -> None:
18 | TABLES = ["users"]
19 |
20 | db.execute(text("SET FOREIGN_KEY_CHECKS = 0"))
21 |
22 | for table in TABLES:
23 | db.execute(text(f"truncate {table}"))
24 | logger.info(f"truncate table={table}")
25 |
26 | db.execute(text("SET FOREIGN_KEY_CHECKS = 1"))
27 |
28 |
29 | def drop_all_tables() -> None:
30 | database.drop_all_tables()
31 |
32 |
33 | def import_seed() -> None:
34 | logger.info("start: import_seed")
35 | seeds_json_files = list(Path(__file__).parent.glob("seeds_json/*.json"))
36 | db: Session = database.session_factory()
37 | try:
38 | truncate_tables(db)
39 |
40 | entities = []
41 | for file in seeds_json_files:
42 | logger.info(f"load seed file={file!s}")
43 | entities.append(load_entities_from_json(str(file)))
44 |
45 | seeder = Seeder(db)
46 | seeder.seed(entities)
47 | db.commit()
48 | logger.info("end: seeds import completed")
49 | except Exception as e:
50 | db.rollback()
51 | logger.error(f"end: seeds import failed. detail={e}")
52 |
53 |
54 | if __name__ == "__main__":
55 | init_logger(settings.LOGGER_CONFIG_PATH)
56 | fire.Fire()
57 |
--------------------------------------------------------------------------------
/frontend_sample/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
16 |
17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
18 |
19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches: [master]
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | timeout-minutes: 20
12 | services:
13 | # テスト用のDBコンテナを起動
14 | db:
15 | image: mysql:8.0
16 | env:
17 | MYSQL_ALLOW_EMPTY_PASSWORD: 1
18 | MYSQL_ROOT_PASSWORD:
19 | MYSQL_DATABASE: docker
20 | MYSQL_USER: docker
21 | MYSQL_PASSWORD: docker
22 | ports:
23 | - 3306:3306
24 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
25 | steps:
26 | - uses: actions/checkout@v3
27 |
28 | - name: set up python
29 | uses: actions/setup-python@v4
30 | with:
31 | python-version: "3.10.12"
32 |
33 | - name: update path
34 | shell: bash
35 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH
36 |
37 | - name: set up .venv cache
38 | uses: actions/cache@v2
39 | id: cache
40 | with:
41 | path: ~/.cache/pip
42 | key: pip-${{ hashFiles('**/requirements-dev.lock') }}
43 | restore-keys: |
44 | pip-
45 |
46 | - name: install packages
47 | run: |
48 | sed '/-e/d' requirements-dev.lock > requirements.txt
49 | pip install -r requirements.txt
50 |
51 | - name: create .env
52 | shell: bash
53 | run: |
54 | cp ".env.test" ".env"
55 |
56 | - name: test
57 | shell: bash
58 | # serviceコンテナへは127.0.0.1でアクセスする必要があるため環境変数を変更
59 | run: |
60 | DB_HOST=127.0.0.1 pytest tests/ --durations=3 -v
61 | timeout-minutes: 10
62 |
--------------------------------------------------------------------------------
/elasticsearch/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | es01:
5 | build:
6 | context: ./elasticsearch
7 | dockerfile: Dockerfile.es
8 | container_name: es01
9 | environment:
10 | - node.name=es01
11 | - discovery.seed_hosts=es02
12 | - cluster.initial_master_nodes=es01,es02
13 | - cluster.name=docker-cluster
14 | - bootstrap.memory_lock=true
15 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
16 | ulimits:
17 | memlock:
18 | soft: -1
19 | hard: -1
20 | volumes:
21 | - esdata01:/usr/share/elasticsearch/data
22 | ports:
23 | - 9200:9200
24 | networks:
25 | - fastapi_network
26 | es02:
27 | build:
28 | context: ./elasticsearch
29 | dockerfile: Dockerfile.es
30 | container_name: es02
31 | environment:
32 | - node.name=es02
33 | - discovery.seed_hosts=es01
34 | - cluster.initial_master_nodes=es01,es02
35 | - cluster.name=docker-cluster
36 | - bootstrap.memory_lock=true
37 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
38 | ulimits:
39 | memlock:
40 | soft: -1
41 | hard: -1
42 | volumes:
43 | - esdata02:/usr/share/elasticsearch/data
44 | networks:
45 | - fastapi_network
46 |
47 | # logstash:
48 | # build:
49 | # context: ./logstash
50 | # dockerfile: Dockerfile
51 | # ports:
52 | # - 5044:5044
53 | # - 9600:9600
54 | # # volumes:
55 | # # - ./logstash:/logstash
56 | # tty: true
57 | # networks:
58 | # - fastapi_network
59 |
60 | volumes:
61 | esdata01:
62 | driver: local
63 | esdata02:
64 | driver: local
65 |
66 | networks:
67 | fastapi_network:
68 | external: true
69 |
--------------------------------------------------------------------------------
/docker-compose.es.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | es01:
5 | build:
6 | context: ./elasticsearch/elasticsearch
7 | dockerfile: Dockerfile.es
8 | container_name: es01
9 | environment:
10 | - node.name=es01
11 | - discovery.seed_hosts=es02
12 | - cluster.initial_master_nodes=es01,es02
13 | - cluster.name=docker-cluster
14 | - bootstrap.memory_lock=true
15 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
16 | ulimits:
17 | memlock:
18 | soft: -1
19 | hard: -1
20 | volumes:
21 | - esdata01:/usr/share/elasticsearch/data
22 | ports:
23 | - 9200:9200
24 | networks:
25 | - fastapi_network
26 | es02:
27 | build:
28 | context: ./elasticsearch/elasticsearch
29 | dockerfile: Dockerfile.es
30 | container_name: es02
31 | environment:
32 | - node.name=es02
33 | - discovery.seed_hosts=es01
34 | - cluster.initial_master_nodes=es01,es02
35 | - cluster.name=docker-cluster
36 | - bootstrap.memory_lock=true
37 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
38 | ulimits:
39 | memlock:
40 | soft: -1
41 | hard: -1
42 | volumes:
43 | - esdata02:/usr/share/elasticsearch/data
44 | networks:
45 | - fastapi_network
46 |
47 | # logstash:
48 | # build:
49 | # context: ./logstash
50 | # dockerfile: Dockerfile
51 | # ports:
52 | # - 5044:5044
53 | # - 9600:9600
54 | # # volumes:
55 | # # - ./logstash:/logstash
56 | # tty: true
57 | # networks:
58 | # - fastapi_network
59 |
60 | volumes:
61 | esdata01:
62 | driver: local
63 | esdata02:
64 | driver: local
65 |
66 | networks:
67 | fastapi_network:
68 | external: true
69 |
--------------------------------------------------------------------------------
/.github/workflows/push_ecr.yml:
--------------------------------------------------------------------------------
1 | name: Push ECR
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | env:
9 | AWS_REGION: ap-northeast-1 # set this to your preferred AWS region, e.g. us-west-1
10 | ECR_REPOSITORY: fastapi-sample-backend # set this to your Amazon ECR repository name
11 |
12 | jobs:
13 | push_ecr:
14 | name: Push ECR
15 | runs-on: ubuntu-latest
16 | environment: production
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v3
21 |
22 | - name: Configure AWS credentials
23 | uses: aws-actions/configure-aws-credentials@v1
24 | with:
25 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
26 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
27 | aws-region: ${{ env.AWS_REGION }}
28 |
29 | - name: Login to Amazon ECR
30 | id: login-ecr
31 | uses: aws-actions/amazon-ecr-login@v1
32 |
33 | - name: Build, tag, and push image to Amazon ECR
34 | id: build-image
35 | env:
36 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
37 | IMAGE_TAG: ${{ github.sha }}
38 | run: |
39 | # Build a docker container and
40 | # push it to ECR so that it can
41 | # be deployed to ECS.
42 | docker build -f Dockerfile.lambda --cache-from=$ECR_REGISTRY/$ECR_REPOSITORY:latest --build-arg BUILDKIT_INLINE_CACHE=1 -t $ECR_REPOSITORY:$IMAGE_TAG .
43 | docker tag $ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
44 | docker tag $ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
45 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
46 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
47 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
48 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/base.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | /**
4 | * FastAPI Sample
5 | * Apps link
/docs
/admin/docs
/other/docs
6 | *
7 | * The version of the OpenAPI document: 0.0.1
8 | *
9 | *
10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
11 | * https://openapi-generator.tech
12 | * Do not edit the class manually.
13 | */
14 |
15 | import type { Configuration } from "./configuration";
16 | // Some imports not used depending on template conditions
17 | // @ts-ignore
18 | import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
19 | import globalAxios from "axios";
20 |
21 | export const BASE_PATH = "http://localhost".replace(/\/+$/, "");
22 |
23 | /**
24 | *
25 | * @export
26 | */
27 | export const COLLECTION_FORMATS = {
28 | csv: ",",
29 | ssv: " ",
30 | tsv: "\t",
31 | pipes: "|",
32 | };
33 |
34 | /**
35 | *
36 | * @export
37 | * @interface RequestArgs
38 | */
39 | export interface RequestArgs {
40 | url: string;
41 | options: AxiosRequestConfig;
42 | }
43 |
44 | /**
45 | *
46 | * @export
47 | * @class BaseAPI
48 | */
49 | export class BaseAPI {
50 | protected configuration: Configuration | undefined;
51 |
52 | constructor(
53 | configuration?: Configuration,
54 | protected basePath: string = BASE_PATH,
55 | protected axios: AxiosInstance = globalAxios,
56 | ) {
57 | if (configuration) {
58 | this.configuration = configuration;
59 | this.basePath = configuration.basePath || this.basePath;
60 | }
61 | }
62 | }
63 |
64 | /**
65 | *
66 | * @export
67 | * @class RequiredError
68 | * @extends {Error}
69 | */
70 | export class RequiredError extends Error {
71 | constructor(public field: string, msg?: string) {
72 | super(msg);
73 | this.name = "RequiredError";
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/app/api/endpoints/users.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, Security
2 | from sqlalchemy.ext.asyncio import AsyncSession
3 |
4 | from app import crud_v2, models, schemas
5 | from app.core.auth import get_current_user
6 | from app.core.database import get_async_db
7 | from app.exceptions.core import APIException
8 | from app.exceptions.error_messages import ErrorMessage
9 |
10 | router = APIRouter()
11 |
12 |
13 | @router.get("/me")
14 | async def get_user_me(
15 | current_user: models.User = Depends(get_current_user),
16 | ) -> schemas.UserResponse:
17 | return current_user
18 |
19 |
20 | @router.get(
21 | "/{id}",
22 | dependencies=[Security(get_current_user, scopes=["admin"])],
23 | )
24 | async def get_user(
25 | id: str,
26 | db: AsyncSession = Depends(get_async_db),
27 | ) -> schemas.UserResponse:
28 | user = await crud_v2.user.get_db_obj_by_id(db, id=id)
29 | if not user:
30 | raise APIException(ErrorMessage.ID_NOT_FOUND)
31 | return user
32 |
33 |
34 | @router.post("")
35 | async def create_user(
36 | data_in: schemas.UserCreate,
37 | db: AsyncSession = Depends(get_async_db),
38 | ) -> schemas.UserResponse:
39 | user = await crud_v2.user.get_by_email(db, email=data_in.email)
40 | if user:
41 | raise APIException(ErrorMessage.ALREADY_REGISTED_EMAIL)
42 | return await crud_v2.user.create(db, obj_in=data_in)
43 |
44 |
45 | @router.put(
46 | "/{id}",
47 | dependencies=[Security(get_current_user, scopes=["admin"])],
48 | )
49 | async def update_user(
50 | id: str,
51 | data_in: schemas.UserUpdate,
52 | db: AsyncSession = Depends(get_async_db),
53 | ) -> schemas.UserResponse:
54 | user = await crud_v2.user.get_db_obj_by_id(db, id=id)
55 | if not user:
56 | raise APIException(ErrorMessage.ID_NOT_FOUND)
57 | return await crud_v2.user.update(db, db_obj=user, obj_in=data_in)
58 |
--------------------------------------------------------------------------------
/app/core/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from functools import lru_cache
3 | from pathlib import Path
4 |
5 | from pydantic_settings import BaseSettings, SettingsConfigDict
6 |
7 |
8 | class Settings(BaseSettings):
9 | # NOTE: .envファイルや環境変数が同名の変数にセットされる
10 | TITLE: str = "FastAPI Sample"
11 | ENV: str = ""
12 | DEBUG: bool = False
13 | VERSION: str = "0.0.1"
14 | CORS_ORIGINS: list[str] = [
15 | "http://localhost:8000",
16 | "http://127.0.0.1:8000",
17 | "http://localhost:3000",
18 | "http://localhost:3333",
19 | ]
20 | BASE_DIR_PATH: str = str(Path(__file__).parent.parent.absolute())
21 | ROOT_DIR_PATH: str = str(Path(__file__).parent.parent.parent.absolute())
22 | DB_HOST: str
23 | DB_PORT: str
24 | DB_NAME: str
25 | DB_USER_NAME: str
26 | DB_PASSWORD: str
27 | API_GATEWAY_STAGE_PATH: str = ""
28 | ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
29 | SECRET_KEY: str = "secret"
30 | LOGGER_CONFIG_PATH: str = os.path.join(BASE_DIR_PATH, "logger_config.yaml")
31 | SENTRY_SDK_DNS: str = ""
32 | MIGRATIONS_DIR_PATH: str = os.path.join(ROOT_DIR_PATH, "alembic")
33 |
34 | def get_database_url(self, is_async: bool = False) -> str:
35 | if is_async:
36 | return (
37 | "mysql+aiomysql://"
38 | f"{self.DB_USER_NAME}:{self.DB_PASSWORD}@"
39 | f"{self.DB_HOST}/{self.DB_NAME}?charset=utf8mb4"
40 | )
41 | else:
42 | return (
43 | "mysql://" f"{self.DB_USER_NAME}:{self.DB_PASSWORD}@" f"{self.DB_HOST}/{self.DB_NAME}?charset=utf8mb4"
44 | )
45 |
46 | model_config = SettingsConfigDict(env_file=".env")
47 |
48 | def get_app_title(self, app_name: str) -> str:
49 | return f"[{self.ENV}]{self.TITLE}({app_name=})"
50 |
51 |
52 | @lru_cache
53 | def get_settings() -> Settings:
54 | return Settings()
55 |
56 |
57 | settings = get_settings()
58 |
--------------------------------------------------------------------------------
/app/crud/user.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.orm import Session
2 |
3 | from app import models, schemas
4 | from app.core.auth import get_password_hash, verify_password
5 | from app.crud.base import CRUDBase
6 |
7 |
8 | class CRUDUser(
9 | CRUDBase[
10 | models.User,
11 | schemas.UserResponse,
12 | schemas.UserCreate,
13 | schemas.UserUpdate,
14 | schemas.UserResponse,
15 | ],
16 | ):
17 | def get_by_email(self, db: Session, *, email: str) -> models.User | None:
18 | return db.query(models.User).filter(models.User.email == email).first()
19 |
20 | def create(self, db: Session, obj_in: schemas.UserCreate) -> models.User:
21 | db_obj = models.User(
22 | email=obj_in.email,
23 | hashed_password=get_password_hash(obj_in.password),
24 | full_name=obj_in.full_name,
25 | )
26 | db.add(db_obj)
27 | db.flush()
28 | db.refresh(db_obj)
29 | return db_obj
30 |
31 | def update( # type: ignore[override]
32 | self,
33 | db: Session,
34 | *,
35 | db_obj: models.User,
36 | obj_in: schemas.UserUpdate,
37 | ) -> models.User:
38 | if obj_in.password:
39 | hashed_password = get_password_hash(obj_in.password)
40 | db_obj.hashed_password = hashed_password
41 | return super().update(db, db_obj=db_obj, update_schema=obj_in)
42 |
43 | def authenticate(
44 | self,
45 | db: Session,
46 | *,
47 | email: str,
48 | password: str,
49 | ) -> models.User | None:
50 | user = self.get_by_email(db, email=email)
51 | if not user:
52 | return None
53 | if not verify_password(password, user.hashed_password):
54 | return None
55 | return user
56 |
57 |
58 | user = CRUDUser(
59 | models.User,
60 | response_schema_class=schemas.UserResponse,
61 | list_response_class=schemas.UsersPagedResponse,
62 | )
63 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/git_push.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
3 | #
4 | # Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
5 |
6 | git_user_id=$1
7 | git_repo_id=$2
8 | release_note=$3
9 | git_host=$4
10 |
11 | if [ "$git_host" = "" ]; then
12 | git_host="github.com"
13 | echo "[INFO] No command line input provided. Set \$git_host to $git_host"
14 | fi
15 |
16 | if [ "$git_user_id" = "" ]; then
17 | git_user_id="GIT_USER_ID"
18 | echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
19 | fi
20 |
21 | if [ "$git_repo_id" = "" ]; then
22 | git_repo_id="GIT_REPO_ID"
23 | echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
24 | fi
25 |
26 | if [ "$release_note" = "" ]; then
27 | release_note="Minor update"
28 | echo "[INFO] No command line input provided. Set \$release_note to $release_note"
29 | fi
30 |
31 | # Initialize the local directory as a Git repository
32 | git init
33 |
34 | # Adds the files in the local repository and stages them for commit.
35 | git add .
36 |
37 | # Commits the tracked changes and prepares them to be pushed to a remote repository.
38 | git commit -m "$release_note"
39 |
40 | # Sets the new remote
41 | git_remote=$(git remote)
42 | if [ "$git_remote" = "" ]; then # git remote not defined
43 |
44 | if [ "$GIT_TOKEN" = "" ]; then
45 | echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
46 | git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
47 | else
48 | git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
49 | fi
50 |
51 | fi
52 |
53 | git pull origin master
54 |
55 | # Pushes (Forces) the changes in the local repository up to the remote repository
56 | echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
57 | git push origin master 2>&1 | grep -v 'To https'
58 |
--------------------------------------------------------------------------------
/app/crud/todo.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import or_
2 | from sqlalchemy.orm import Session, contains_eager
3 |
4 | from app import crud, models, schemas
5 |
6 | from .base import CRUDBase
7 |
8 |
9 | class CRUDTodo(
10 | CRUDBase[
11 | models.Todo,
12 | schemas.TodoResponse,
13 | schemas.TodoCreate,
14 | schemas.TodoUpdate,
15 | schemas.TodosPagedResponse,
16 | ],
17 | ):
18 | def get_paged_list( # type: ignore[override]
19 | self,
20 | db: Session,
21 | paging_query_in: schemas.PagingQueryIn,
22 | q: str | None = None,
23 | sort_query_in: schemas.SortQueryIn | None = None,
24 | ) -> schemas.TodosPagedResponse:
25 | where_clause = (
26 | [
27 | or_(
28 | models.Todo.title.ilike(f"%{q}%"),
29 | models.Todo.description.ilike(f"%{q}%"),
30 | ),
31 | ]
32 | if q
33 | else []
34 | )
35 | return super().get_paged_list(
36 | db,
37 | paging_query_in=paging_query_in,
38 | where_clause=where_clause,
39 | sort_query_in=sort_query_in,
40 | )
41 |
42 | def add_tags_to_todo(
43 | self,
44 | db: Session,
45 | todo: models.Todo,
46 | tags_in: list[schemas.TagCreate],
47 | ) -> models.Todo:
48 | tags = crud.tag.upsert_tags(db, tags_in=tags_in)
49 | for tag in tags:
50 | todo.tags.append(tag) # noqa: PERF402
51 | db.flush()
52 |
53 | # one-to-many joined response one query
54 | todo = (
55 | db.query(models.Todo)
56 | .outerjoin(models.Todo.tags)
57 | .options(contains_eager(models.Todo.tags))
58 | .filter(models.Todo.id == todo.id)
59 | .first()
60 | )
61 |
62 | return todo
63 |
64 |
65 | todo = CRUDTodo(
66 | models.Todo,
67 | response_schema_class=schemas.TodoResponse,
68 | list_response_class=schemas.TodosPagedResponse,
69 | )
70 |
--------------------------------------------------------------------------------
/app/exceptions/error_messages.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from starlette import status
4 |
5 |
6 | class BaseMessage:
7 | """メッセージクラスのベース."""
8 |
9 | text: str
10 | status_code: int = status.HTTP_400_BAD_REQUEST
11 |
12 | def __init__(self, param: Any | None = None) -> None:
13 | self.param = param
14 |
15 | def __str__(self) -> str:
16 | return self.__class__.__name__
17 |
18 |
19 | class ErrorMessage:
20 | """エラーメッセージクラス.
21 |
22 | Notes
23 | -----
24 | BaseMessagを継承することで
25 | Class呼び出し時にClass名がエラーコードになり、.textでエラーメッセージも取得できるため
26 | エラーコードと、メッセージの管理が直感的に行える。
27 |
28 | """
29 |
30 | # 共通
31 | class INTERNAL_SERVER_ERROR(BaseMessage):
32 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
33 | text = "システムエラーが発生しました、管理者に問い合わせてください"
34 |
35 | class FAILURE_LOGIN(BaseMessage):
36 | text = "ログインが失敗しました"
37 |
38 | class NOT_FOUND(BaseMessage):
39 | text = "{}が見つかりません"
40 |
41 | class ID_NOT_FOUND(BaseMessage):
42 | status_code = status.HTTP_404_NOT_FOUND
43 | text = "このidは見つかりません"
44 |
45 | class PARAM_IS_NOT_SET(BaseMessage):
46 | text = "{}がセットされていません"
47 |
48 | class ALREADY_DELETED(BaseMessage):
49 | text = "既に削除済です"
50 |
51 | class SOFT_DELETE_NOT_SUPPORTED(BaseMessage):
52 | text = "論理削除には未対応です"
53 |
54 | class COLUMN_NOT_ALLOWED(BaseMessage):
55 | text = "このカラムは指定できません"
56 |
57 | # ユーザー
58 | class ALREADY_REGISTED_EMAIL(BaseMessage):
59 | text = "登録済のメールアドレスです"
60 |
61 | class INCORRECT_CURRENT_PASSWORD(BaseMessage):
62 | text = "現在のパスワードが間違っています"
63 |
64 | class INCORRECT_EMAIL_OR_PASSWORD(BaseMessage):
65 | status_code = status.HTTP_403_FORBIDDEN
66 | text = "メールアドレスまたはパスワードが正しくありません"
67 |
68 | class PERMISSION_ERROR(BaseMessage):
69 | text = "実行権限がありません"
70 |
71 | class CouldNotValidateCredentials(BaseMessage):
72 | status_code = status.HTTP_403_FORBIDDEN
73 | text = "認証エラー"
74 |
--------------------------------------------------------------------------------
/tests/base.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | from httpx import AsyncClient
4 | from starlette import status
5 |
6 | from tests.testing_utils import assert_dict_part
7 |
8 |
9 | async def assert_create(
10 | uri: str,
11 | client: AsyncClient,
12 | data_in: dict[str, Any],
13 | expected_status: int,
14 | expected_data: dict[str, Any] | None,
15 | ) -> None:
16 | res = await client.post(uri, json=data_in)
17 | assert res.status_code == expected_status
18 | if expected_status == status.HTTP_200_OK:
19 | res_data = res.json()
20 | assert_dict_part(res_data, expected_data)
21 |
22 |
23 | async def assert_update(
24 | uri: str,
25 | client: AsyncClient,
26 | id: str,
27 | data_in: dict[str, Any],
28 | expected_status: int,
29 | expected_data: dict[str, Any] | None,
30 | ) -> None:
31 | res = await client.patch(f"{uri}/{id}", json=data_in)
32 | assert res.status_code == expected_status
33 | if expected_status == status.HTTP_200_OK:
34 | res_data = res.json()
35 | assert_dict_part(res_data, expected_data)
36 |
37 |
38 | async def assert_get_by_id(
39 | uri: str,
40 | client: AsyncClient,
41 | id: str,
42 | expected_status: int,
43 | expected_data: dict[str, Any] | None,
44 | ) -> None:
45 | res = await client.get(f"{uri}/{id}")
46 | assert res.status_code == expected_status
47 | if expected_status == status.HTTP_200_OK:
48 | res_data = res.json()
49 | assert_dict_part(res_data, expected_data)
50 |
51 |
52 | async def assert_get_paged_list(
53 | uri: str,
54 | client: AsyncClient,
55 | params: dict[str, Any],
56 | expected_status: int,
57 | expected_first_data: dict[str, Any],
58 | expected_paging_meta: dict[str, Any],
59 | ) -> None:
60 | res = await client.get(uri, params=params)
61 | assert res.status_code == expected_status
62 | if expected_status == status.HTTP_200_OK:
63 | res_data = res.json()
64 | assert res_data["data"]
65 | assert_dict_part(res_data["data"][0], expected_first_data)
66 | assert_dict_part(res_data["meta"], expected_paging_meta)
67 |
--------------------------------------------------------------------------------
/app/crud_v2/user.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.ext.asyncio import AsyncSession
2 | from sqlalchemy.sql import select
3 |
4 | from app import models, schemas
5 | from app.core.auth import get_password_hash, verify_password
6 | from app.crud_v2.base import CRUDV2Base
7 |
8 |
9 | class CRUDUser(
10 | CRUDV2Base[
11 | models.User,
12 | schemas.UserResponse,
13 | schemas.UserCreate,
14 | schemas.UserUpdate,
15 | schemas.UserResponse,
16 | ]
17 | ):
18 | async def get_by_email(self, db: AsyncSession, *, email: str) -> models.User | None:
19 | stmt = select(models.User).where(models.User.email == email)
20 | return (await db.execute(stmt)).scalars().first()
21 | # return db.query(models.User).filter(models.User.email == email).first()
22 |
23 | async def create(self, db: AsyncSession, obj_in: schemas.UserCreate) -> models.User:
24 | db_obj = models.User(
25 | email=obj_in.email,
26 | hashed_password=get_password_hash(obj_in.password),
27 | full_name=obj_in.full_name,
28 | )
29 | db.add(db_obj)
30 | await db.flush()
31 | await db.refresh(db_obj)
32 | return db_obj
33 |
34 | async def update( # type: ignore[override]
35 | self, db: AsyncSession, *, db_obj: models.User, obj_in: schemas.UserUpdate
36 | ) -> models.User:
37 | if obj_in.password:
38 | hashed_password = get_password_hash(obj_in.password)
39 | db_obj.hashed_password = hashed_password
40 | return await super().update(db, db_obj=db_obj, update_schema=obj_in)
41 |
42 | async def authenticate(self, db: AsyncSession, *, email: str, password: str) -> models.User | None:
43 | user = await self.get_by_email(db, email=email)
44 | if not user:
45 | return None
46 | if not verify_password(password, user.hashed_password):
47 | return None
48 | return user
49 |
50 |
51 | user = CRUDUser(
52 | models.User,
53 | response_schema_class=schemas.UserResponse,
54 | list_response_class=schemas.UsersPagedResponse,
55 | )
56 |
--------------------------------------------------------------------------------
/frontend_sample/src/components/templates/todos/TodoCreateTemplate/TodoCreateTemplate.tsx:
--------------------------------------------------------------------------------
1 | import { TodoCreate, TodoUpdate } from "api_clients";
2 | import {
3 | useCreateTodo,
4 | useGetTodoById,
5 | useUpdateTodo,
6 | } from "lib/hooks/api/todos";
7 | import { useRouter } from "next/router";
8 | import { FC, useCallback, useEffect, useState } from "react";
9 |
10 | const TodoCreateTemplate: FC = () => {
11 | const [requestData, setRequestData] = useState({
12 | title: "",
13 | description: "",
14 | });
15 | const [isSuccess, setIsSuccess] = useState(false);
16 | const router = useRouter();
17 | const { mutateAsync: createMutateAsync, isLoading: isLoading } =
18 | useCreateTodo();
19 |
20 | const handleChangeValue = useCallback(
21 | (e: React.ChangeEvent): void => {
22 | const { name, value } = e.target;
23 | setRequestData({
24 | ...requestData,
25 | [name]: value,
26 | });
27 | },
28 | [setRequestData, requestData],
29 | );
30 |
31 | const handleClickUpdateButton = async (): Promise => {
32 | if (!requestData) return;
33 | await createMutateAsync(
34 | requestData,
35 | (responseData) => {
36 | setIsSuccess(true);
37 | console.log("success.", responseData);
38 | },
39 | (error) => {
40 | setIsSuccess(false);
41 | console.log("error.", error);
42 | },
43 | );
44 | };
45 | return (
46 |
47 |
Todo Create
48 |
49 | タイトル:
50 |
55 |
56 |
57 | 説明:
58 |
63 |
64 |
65 |
66 |
{isLoading && "処理中"}
67 |
{!isLoading && isSuccess && "完了"}
68 |
69 | );
70 | };
71 |
72 | export default TodoCreateTemplate;
73 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | db:
5 | image: mysql:8.0
6 | restart: always
7 | # command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
8 | command: >
9 | mysqld --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
10 | ports:
11 | - "3307:3306" # port重複防止のため、local側は3306以外とする
12 | environment:
13 | MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
14 | # MYSQL_ROOT_PASSWORD: docker
15 | MYSQL_DATABASE: docker
16 | MYSQL_USER: docker
17 | MYSQL_PASSWORD: docker
18 | networks:
19 | - fastapi_network
20 | volumes:
21 | - db_volume:/var/lib/mysql
22 |
23 | web:
24 | # platform: linux/amd64
25 | build:
26 | context: .
27 | dockerfile: Dockerfile
28 | environment:
29 | SQLALCHEMY_WARN_20: 1
30 | healthcheck:
31 | test: "curl -f http://localhost:80/docs || exit 1"
32 | interval: 5s
33 | timeout: 2s
34 | retries: 3
35 | start_period: 5s
36 | depends_on:
37 | - db
38 | command: [
39 | "/bin/sh",
40 | "-c",
41 | "alembic upgrade heads && uvicorn app.main:app --host 0.0.0.0 --port 80 --reload --log-config ./app/logger_config.yaml --proxy-headers --forwarded-allow-ips='*'"
42 | ]
43 | ports:
44 | - 8888:80
45 | - 8889:81 # for debug launch
46 | volumes:
47 | - .:/backend
48 | - /backend/.venv
49 | links:
50 | - db
51 | restart: always
52 | networks:
53 | - fastapi_network
54 |
55 | # openapiのclient用のコードを自動生成するコンテナ
56 | openapi-generator:
57 | image: openapitools/openapi-generator-cli
58 | depends_on:
59 | web:
60 | condition: service_healthy
61 | volumes:
62 | - ./frontend_sample:/fontend_sample
63 | command: generate -i http://web/openapi.json -g typescript-axios -o /fontend_sample/src/api_clients --skip-validate-spec
64 | networks:
65 | - fastapi_network
66 |
67 |
68 |
69 | volumes:
70 | db_volume:
71 | driver: local
72 |
73 | networks:
74 | fastapi_network:
75 | name: fastapi_network
76 | driver: bridge
77 |
--------------------------------------------------------------------------------
/tests/testing_utils.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 |
4 | def assert_dict_part(
5 | result_dict: dict[Any, Any],
6 | expected_dict: dict[Any, Any],
7 | exclude_fields: list[str] | None = None,
8 | expected_delete: bool = False,
9 | ) -> None:
10 | """dictの部分一致でのassertion
11 |
12 | expected_dictに指定したkeyのみをチェックする
13 |
14 | Args:
15 | result_dict: チェック対象のdict
16 | expected_dict: 期待値のdict
17 | exclude_fields: チェック対象から除外するfieldのlist
18 | expected_delete: Trueの場合、削除済データを取得する場合は、deleted_atにデータが存在することのみをチェックする
19 | """
20 | _exclude_fields = exclude_fields or []
21 | # 削除済データを取得する場合は、deleted_atにデータが存在することのみをチェックする
22 | if expected_delete:
23 | assert result_dict.get("deleted_at") or result_dict.get("deletedAt")
24 | _exclude_fields.extend(["deleted_at", "deletedAt"])
25 |
26 | for key, value in expected_dict.items():
27 | if key in _exclude_fields:
28 | continue
29 | assert result_dict.get(key) == value, f"key={key}. result_value={result_dict.get(key)}. expected_value={value}"
30 |
31 |
32 | def assert_is_deleted(result_dict: dict[Any, Any]) -> None:
33 | assert result_dict.get("deleted_at")
34 |
35 |
36 | def assert_successful_status_code(status_code: int) -> None:
37 | assert 300 > status_code >= 200, f"status_code={status_code}. expected: 300 > status_code >= 200"
38 |
39 |
40 | def assert_failed_status_code(status_code: int) -> None:
41 | assert not (300 > status_code >= 200), f"status_code={status_code}. expected: not(300 > status_code >= 200)"
42 |
43 |
44 | def assert_crud_model(result_obj: Any, expected_obj: Any, exclude_fileds: list[str] | None = None) -> None:
45 | """sqlalchemy-modelのassertion"""
46 | expected_dict = expected_obj.__dict__.copy()
47 | del expected_dict["_sa_instance_state"]
48 | _exclude_fileds = exclude_fileds or []
49 | for key, value in expected_dict.items():
50 | if key in _exclude_fileds:
51 | continue
52 | assert (
53 | getattr(result_obj, key) == value
54 | ), f"{key=}, result_value={getattr(result_obj, key)}, expected_value={value}"
55 |
56 |
57 | def assert_api_error(result_error: Any, expected_error: Any) -> None:
58 | assert result_error.detail["error_code"] == expected_error.detail["error_code"]
59 |
--------------------------------------------------------------------------------
/app/models/base.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import Any
3 |
4 | from sqlalchemy import DateTime, String, event, func, orm
5 | from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
6 | from sqlalchemy.sql.functions import current_timestamp
7 |
8 | from app.core.logger import get_logger
9 | from app.core.utils import get_ulid
10 |
11 | logger = get_logger(__name__)
12 |
13 |
14 | class Base(DeclarativeBase):
15 | pass
16 |
17 |
18 | class ModelBaseMixin:
19 | id: Mapped[str] = mapped_column(String(32), primary_key=True, default=get_ulid)
20 | created_at: Mapped[datetime] = mapped_column(
21 | DateTime,
22 | nullable=False,
23 | server_default=current_timestamp(),
24 | )
25 | updated_at: Mapped[datetime] = mapped_column(
26 | DateTime,
27 | nullable=False,
28 | default=current_timestamp(),
29 | onupdate=func.utc_timestamp(),
30 | )
31 | deleted_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
32 |
33 |
34 | class ModelBaseMixinWithoutDeletedAt:
35 | id: Mapped[str] = mapped_column(String(32), primary_key=True, default=get_ulid)
36 | created_at: Mapped[datetime] = mapped_column(
37 | DateTime,
38 | nullable=False,
39 | server_default=current_timestamp(),
40 | )
41 | updated_at: Mapped[datetime] = mapped_column(
42 | DateTime,
43 | nullable=False,
44 | default=current_timestamp(),
45 | onupdate=func.utc_timestamp(),
46 | )
47 |
48 |
49 | @event.listens_for(Session, "do_orm_execute")
50 | def _add_filtering_deleted_at(execute_state: Any) -> None:
51 | """論理削除用のfilterを自動的に適用する
52 | 以下のようにすると、論理削除済のデータも含めて取得可能
53 | select(...).filter(...).execution_options(include_deleted=True).
54 | """
55 | if (
56 | execute_state.is_select
57 | and not execute_state.is_column_load
58 | and not execute_state.is_relationship_load
59 | and not execute_state.execution_options.get("include_deleted", False)
60 | ):
61 | execute_state.statement = execute_state.statement.options(
62 | orm.with_loader_criteria( # ignore[mypy]
63 | ModelBaseMixin,
64 | lambda cls: cls.deleted_at.is_(None),
65 | include_aliases=True,
66 | ),
67 | )
68 |
--------------------------------------------------------------------------------
/alembic/env.py:
--------------------------------------------------------------------------------
1 | from logging.config import fileConfig
2 |
3 | from sqlalchemy import engine_from_config, pool
4 |
5 | from alembic import context
6 | from app.core.config import settings
7 | from app.models.base import Base
8 |
9 | # this is the Alembic Config object, which provides
10 | # access to the values within the .ini file in use.
11 | config = context.config
12 |
13 | # Interpret the config file for Python logging.
14 | # This line sets up loggers basically.
15 | if config.config_file_name is not None:
16 | fileConfig(config.config_file_name)
17 |
18 |
19 | # add your model's MetaData object here
20 | # for 'autogenerate' support
21 | # .envにセットされたDB設定を使用
22 | uri = config.get_main_option("sqlalchemy.url")
23 | config.set_main_option("sqlalchemy.url", uri or settings.get_database_url())
24 |
25 | target_metadata = Base.metadata
26 |
27 | # other values from the config, defined by the needs of env.py,
28 | # can be acquired:
29 | # ... etc.
30 |
31 |
32 | def run_migrations_offline():
33 | """Run migrations in 'offline' mode.
34 |
35 | This configures the context with just a URL
36 | and not an Engine, though an Engine is acceptable
37 | here as well. By skipping the Engine creation
38 | we don't even need a DBAPI to be available.
39 |
40 | Calls to context.execute() here emit the given string to the
41 | script output.
42 |
43 | """
44 | url = config.get_main_option("sqlalchemy.url")
45 | context.configure(
46 | url=url,
47 | target_metadata=target_metadata,
48 | literal_binds=True,
49 | dialect_opts={"paramstyle": "named"},
50 | )
51 |
52 | with context.begin_transaction():
53 | context.run_migrations()
54 |
55 |
56 | def run_migrations_online():
57 | """Run migrations in 'online' mode.
58 |
59 | In this scenario we need to create an Engine
60 | and associate a connection with the context.
61 |
62 | """
63 | connectable = engine_from_config(
64 | config.get_section(config.config_ini_section),
65 | prefix="sqlalchemy.",
66 | poolclass=pool.NullPool,
67 | )
68 |
69 | with connectable.connect() as connection:
70 | context.configure(connection=connection, target_metadata=target_metadata)
71 |
72 | with context.begin_transaction():
73 | context.run_migrations()
74 |
75 |
76 | if context.is_offline_mode():
77 | run_migrations_offline()
78 | else:
79 | run_migrations_online()
80 |
--------------------------------------------------------------------------------
/frontend_sample/src/components/templates/todos/TodoUpdateTemplate/TodoUpdateTemplate.tsx:
--------------------------------------------------------------------------------
1 | import { TodoUpdate } from "api_clients";
2 | import { useGetTodoById, useUpdateTodo } from "lib/hooks/api/todos";
3 | import { useRouter } from "next/router";
4 | import { FC, useCallback, useEffect, useState } from "react";
5 |
6 | const TodoUpdateTemplate: FC = () => {
7 | const [requestData, setRequestData] = useState();
8 | const [id, setId] = useState("");
9 | const [isSuccess, setIsSuccess] = useState(false);
10 | const router = useRouter();
11 | const { data: todoResponse } = useGetTodoById(router.query.id as string);
12 | const { mutateAsync: updateMutateAsync, isLoading: isLoading } =
13 | useUpdateTodo(id);
14 |
15 | const handleChangeValue = useCallback(
16 | (e: React.ChangeEvent): void => {
17 | const { name, value } = e.target;
18 | setRequestData({
19 | ...requestData,
20 | [name]: value,
21 | });
22 | },
23 | [setRequestData, requestData],
24 | );
25 |
26 | useEffect(() => {
27 | setId(router.query.id as string);
28 | }, [router.query.id]);
29 |
30 | useEffect(() => {
31 | if (!todoResponse) return;
32 | setRequestData(todoResponse);
33 | }, [todoResponse]);
34 |
35 | const handleClickUpdateButton = async (): Promise => {
36 | if (!requestData) return;
37 | await updateMutateAsync(
38 | requestData,
39 | (responseData) => {
40 | setIsSuccess(true);
41 | console.log("success.", responseData);
42 | },
43 | (error) => {
44 | setIsSuccess(false);
45 | console.log("error.", error);
46 | },
47 | );
48 | };
49 | return (
50 |
51 |
Todo Edit
52 |
id:{id}
53 |
54 | タイトル:
55 |
60 |
61 |
62 | 説明:
63 |
68 |
69 |
70 |
71 |
{isLoading && "処理中"}
72 |
{!isLoading && isSuccess && "完了"}
73 |
74 | );
75 | };
76 |
77 | export default TodoUpdateTemplate;
78 |
--------------------------------------------------------------------------------
/app/crud_v2/todo.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import or_
2 | from sqlalchemy.dialects.mysql import insert
3 | from sqlalchemy.ext.asyncio import AsyncSession
4 | from sqlalchemy.orm import Session, contains_eager
5 | from sqlalchemy.sql import select
6 |
7 | from app import crud_v2, models, schemas
8 |
9 | from .base import CRUDV2Base
10 |
11 |
12 | class CRUDTodo(
13 | CRUDV2Base[
14 | models.Todo,
15 | schemas.TodoResponse,
16 | schemas.TodoCreate,
17 | schemas.TodoUpdate,
18 | schemas.TodosPagedResponse,
19 | ],
20 | ):
21 | async def get_paged_list( # type: ignore[override]
22 | self,
23 | db: AsyncSession,
24 | paging_query_in: schemas.PagingQueryIn,
25 | q: str | None = None,
26 | sort_query_in: schemas.SortQueryIn | None = None,
27 | include_deleted: bool = False,
28 | ) -> schemas.TodosPagedResponse:
29 | where_clause = (
30 | [
31 | or_(
32 | models.Todo.title.ilike(f"%{q}%"),
33 | models.Todo.description.ilike(f"%{q}%"),
34 | ),
35 | ]
36 | if q
37 | else []
38 | )
39 | return await super().get_paged_list(
40 | db,
41 | paging_query_in=paging_query_in,
42 | where_clause=where_clause,
43 | sort_query_in=sort_query_in,
44 | include_deleted=include_deleted,
45 | )
46 |
47 | def add_tags_to_todo(
48 | self,
49 | db: Session,
50 | todo: models.Todo,
51 | tags_in: list[schemas.TagCreate],
52 | ) -> models.Todo:
53 | tags = crud_v2.tag.upsert_tags(db, tags_in=tags_in)
54 | todos_tags_data = [{"todo_id": todo.id, "tag_id": tag.id} for tag in tags]
55 |
56 | # Tagを紐づけ
57 | stmt = insert(models.TodoTag).values(todos_tags_data)
58 | stmt = stmt.on_duplicate_key_update(tag_id=stmt.inserted.tag_id)
59 | db.execute(stmt)
60 |
61 | stmt = (
62 | select(models.Todo)
63 | .outerjoin(models.Todo.tags)
64 | .options(contains_eager(models.Todo.tags))
65 | .where(models.Todo.id == todo.id)
66 | )
67 | todo = db.execute(stmt).scalars().unique().first()
68 |
69 | return todo
70 |
71 |
72 | todo = CRUDTodo(
73 | models.Todo,
74 | response_schema_class=schemas.TodoResponse,
75 | list_response_class=schemas.TodosPagedResponse,
76 | )
77 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | # - repo: https://github.com/timothycrosley/isort
3 | # rev: 5.12.0
4 | # hooks:
5 | # - id: isort
6 | # additional_dependencies: [toml]
7 |
8 | # - repo: https://github.com/PyCQA/autoflake
9 | # rev: v2.0.2
10 | # hooks:
11 | # - id: autoflake
12 | # args:
13 | # - --in-place
14 | # - --recursive
15 | # - --expand-star-imports
16 | # - --remove-all-unused-imports
17 | # - --ignore-init-module-imports
18 | # - --remove-unused-variables
19 |
20 | # - repo: https://github.com/PyCQA/flake8
21 | # rev: 6.0.0
22 | # hooks:
23 | # - id: flake8
24 | # args:
25 | # - app/
26 |
27 | # flake8, isort, autoflakeはより高速で一元管理可能なruffに置き換える
28 | - repo: https://github.com/charliermarsh/ruff-pre-commit
29 | rev: "v0.0.277"
30 | hooks:
31 | - id: ruff
32 | args: [--fix, --exit-non-zero-on-fix]
33 |
34 | # ruffは一部の機能が未実装のため、一旦はblackで代用する
35 | - repo: https://github.com/psf/black
36 | rev: 23.3.0
37 | hooks:
38 | - id: black
39 | args:
40 | - app/
41 |
42 | - repo: https://github.com/pre-commit/mirrors-mypy
43 | rev: v1.2.0
44 | hooks:
45 | - id: mypy
46 | exclude: ^tests/|^alembic/
47 | additional_dependencies: [pydantic, sqlalchemy, types-PyYAML==6.0.7]
48 |
49 | - repo: https://github.com/pre-commit/pre-commit-hooks
50 | rev: v4.4.0
51 | hooks:
52 | - id: trailing-whitespace
53 | - id: end-of-file-fixer
54 | - id: check-yaml
55 | - id: check-added-large-files
56 |
57 | - repo: https://github.com/pre-commit/mirrors-prettier
58 | rev: v3.0.0-alpha.6
59 | hooks:
60 | - id: prettier
61 | entry: prettier --write
62 | files: ^frontend_sample/.*\.(css|less|scsshtml|js|jsxjson|md|markdown|mdown|mkdn|mdx|ts|tsx|yaml|yml)$
63 | exclude: (^frontend_sample/public/)
64 |
65 | # - repo: https://github.com/python-poetry/poetry
66 | # rev: "1.4.0"
67 | # hooks:
68 | # - id: poetry-check
69 | # - id: poetry-lock
70 | # - id: poetry-export
71 | # args: ["-f", "requirements.txt", "-o", "requirements.txt"]
72 | # - id: poetry-export # for dev
73 | # args:
74 | # [
75 | # "--with",
76 | # "dev",
77 | # "-f",
78 | # "requirements.txt",
79 | # "-o",
80 | # "requirements-dev.txt",
81 | # ]
82 |
--------------------------------------------------------------------------------
/frontend_sample/src/lib/hooks/api/index.ts:
--------------------------------------------------------------------------------
1 | import type { AxiosError, AxiosResponse } from "axios";
2 | import type {
3 | QueryFunction,
4 | UseQueryOptions,
5 | UseQueryResult,
6 | MutateFunction,
7 | UseMutationOptions,
8 | UseMutationResult,
9 | } from "react-query";
10 | import { useQuery, useMutation } from "react-query";
11 |
12 | export type UseGetResult = Omit<
13 | UseQueryResult, AxiosError>,
14 | "data" | "error"
15 | > & {
16 | data?: TData;
17 | error?: TError;
18 | };
19 |
20 | export type UsePostResult<
21 | TData,
22 | TError = ErrorResponse,
23 | TVariables = unknown,
24 | > = Omit<
25 | UseMutationResult, AxiosError, TVariables>,
26 | "mutateAsync"
27 | > & {
28 | mutateAsync: (
29 | variables: TVariables,
30 | onSuccess?: (data: TData) => unknown,
31 | onFailure?: (error?: TError) => unknown,
32 | ) => Promise;
33 | };
34 |
35 | export type ErrorResponse = {
36 | errorCode?: string;
37 | errorMessage?: string;
38 | };
39 |
40 | export const useGet = (
41 | key: unknown[] | string,
42 | queryFn: QueryFunction>,
43 | options?: UseQueryOptions, AxiosError>,
44 | ): UseGetResult => {
45 | const result = useQuery<
46 | AxiosResponse,
47 | AxiosError,
48 | AxiosResponse
49 | >(key, queryFn, options);
50 | return {
51 | ...result,
52 | data: result.data?.data,
53 | error: result.error?.response?.data,
54 | };
55 | };
56 |
57 | export const usePost = (
58 | key: unknown[] | string,
59 | mutationFn: MutateFunction<
60 | AxiosResponse,
61 | AxiosError,
62 | TVariables
63 | >,
64 | options?: Omit<
65 | UseMutationOptions, AxiosError, TVariables>,
66 | "mutationFn" | "mutationKey"
67 | >,
68 | ): UsePostResult => {
69 | const result = useMutation<
70 | AxiosResponse,
71 | AxiosError,
72 | TVariables
73 | >(key, mutationFn, options);
74 | return {
75 | ...result,
76 | mutateAsync: async (
77 | variables: TVariables,
78 | onSuccess?: (data: TData) => unknown,
79 | onFailure?: (error?: TError) => unknown,
80 | ) =>
81 | result
82 | .mutateAsync(variables)
83 | .then((res) => onSuccess?.(res.data))
84 | .catch((error: AxiosError) => {
85 | onFailure ? onFailure?.(error?.response?.data) : console.log(error);
86 | }),
87 | };
88 | };
89 |
--------------------------------------------------------------------------------
/frontend_sample/src/components/templates/todos/TodosListTemplate/TodosListTemplate.tsx:
--------------------------------------------------------------------------------
1 | import { useGetTodos } from "lib/hooks/api/todos";
2 | import { useRouter } from "next/router";
3 | import { FC, useState } from "react";
4 |
5 | const TodosListTemplate: FC = () => {
6 | const router = useRouter();
7 | const [page, setPage] = useState(1);
8 | const [perPage, setPerPage] = useState(5);
9 | const [keyword, setKeyword] = useState("");
10 | const { data: todosResponse } = useGetTodos({
11 | q: keyword,
12 | page: page,
13 | perPage: perPage,
14 | });
15 |
16 | return (
17 |
18 |
Todo List
19 |
20 |
21 |
22 |
23 |
43 |
50 |
60 |
61 | page: {todosResponse?.meta?.currentPage}/
62 | {todosResponse?.meta?.totalPageCount}
63 |
64 |
total count: {todosResponse?.meta?.totalDataCount}
65 |
66 | {todosResponse?.data?.map((d, i) => {
67 | return (
68 |
69 |
70 | {i + 1}:{d.title}
71 |
72 |
73 | {d.tags?.map((tag) => {
74 | return
{tag.name}
;
75 | })}
76 |
77 |
78 |
81 |
82 |
83 |
84 | );
85 | })}
86 |
87 | );
88 | };
89 |
90 | export default TodosListTemplate;
91 |
--------------------------------------------------------------------------------
/app/main.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import sentry_sdk
4 | from fastapi import FastAPI
5 | from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
6 | from sentry_sdk.integrations.logging import LoggingIntegration
7 | from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
8 | from starlette.middleware.cors import CORSMiddleware
9 |
10 | from app.api.apps import admin_app, other_app
11 | from app.api.endpoints import auth, tasks, todos, users
12 | from app.app_manager import FastAPIAppManager
13 | from app.core.config import settings
14 | from app.core.logger import get_logger
15 |
16 | # loggingセットアップ
17 |
18 | logger = get_logger(__name__)
19 |
20 |
21 | class NoParsingFilter(logging.Filter):
22 | def filter(self, record: logging.LogRecord) -> bool:
23 | return not record.getMessage().find("/docs") >= 0
24 |
25 |
26 | # /docsのログが大量に表示されるのを防ぐ
27 | logging.getLogger("uvicorn.access").addFilter(NoParsingFilter())
28 |
29 | sentry_logging = LoggingIntegration(level=logging.INFO, event_level=logging.ERROR)
30 |
31 | app = FastAPI(
32 | title=settings.TITLE,
33 | version=settings.VERSION,
34 | debug=settings.DEBUG or False,
35 | )
36 | app_manager = FastAPIAppManager(root_app=app)
37 |
38 |
39 | if settings.SENTRY_SDK_DNS:
40 | sentry_sdk.init(
41 | dsn=settings.SENTRY_SDK_DNS,
42 | integrations=[sentry_logging, SqlalchemyIntegration()],
43 | environment=settings.ENV,
44 | )
45 |
46 |
47 | app.add_middleware(SentryAsgiMiddleware)
48 |
49 | app.add_middleware(
50 | CORSMiddleware,
51 | allow_origins=[str(origin) for origin in settings.CORS_ORIGINS],
52 | allow_origin_regex=r"^https?:\/\/([\w\-\_]{1,}\.|)example\.com",
53 | allow_methods=["*"],
54 | allow_headers=["*"],
55 | )
56 |
57 |
58 | @app.get("/", tags=["info"])
59 | def get_info() -> dict[str, str]:
60 | return {"title": settings.TITLE, "version": settings.VERSION}
61 |
62 |
63 | app.include_router(auth.router, tags=["Auth"], prefix="/auth")
64 | app.include_router(users.router, tags=["Users"], prefix="/users")
65 | app.include_router(todos.router, tags=["Todos"], prefix="/todos")
66 | app.include_router(tasks.router, tags=["Tasks"], prefix="/tasks")
67 |
68 | # appを分割する場合は、add_appで別のappを追加する
69 | app_manager.add_app(path="admin", app=admin_app.app)
70 | app_manager.add_app(path="other", app=other_app.app)
71 | app_manager.setup_apps_docs_link()
72 |
73 | # debugモード時はfastapi-tool-barを有効化する
74 | if settings.DEBUG:
75 | from debug_toolbar.middleware import DebugToolbarMiddleware
76 |
77 | app.add_middleware(
78 | DebugToolbarMiddleware,
79 | panels=["app.core.database.SQLAlchemyPanel"],
80 | )
81 |
--------------------------------------------------------------------------------
/frontend_sample/src/styles/Home.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding: 0 2rem;
3 | }
4 |
5 | .main {
6 | min-height: 100vh;
7 | padding: 4rem 0;
8 | flex: 1;
9 | display: flex;
10 | flex-direction: column;
11 | justify-content: center;
12 | align-items: center;
13 | }
14 |
15 | .footer {
16 | display: flex;
17 | flex: 1;
18 | padding: 2rem 0;
19 | border-top: 1px solid #eaeaea;
20 | justify-content: center;
21 | align-items: center;
22 | }
23 |
24 | .footer a {
25 | display: flex;
26 | justify-content: center;
27 | align-items: center;
28 | flex-grow: 1;
29 | }
30 |
31 | .title a {
32 | color: #0070f3;
33 | text-decoration: none;
34 | }
35 |
36 | .title a:hover,
37 | .title a:focus,
38 | .title a:active {
39 | text-decoration: underline;
40 | }
41 |
42 | .link a {
43 | color: #0070f3;
44 | text-decoration: none;
45 | }
46 |
47 | .link a:hover,
48 | .link a:focus,
49 | .link a:active {
50 | text-decoration: underline;
51 | }
52 |
53 | .title {
54 | margin: 0;
55 | line-height: 1.15;
56 | font-size: 4rem;
57 | }
58 |
59 | .title,
60 | .description {
61 | text-align: center;
62 | }
63 |
64 | .description {
65 | margin: 4rem 0;
66 | line-height: 1.5;
67 | font-size: 1.5rem;
68 | }
69 |
70 | .code {
71 | background: #fafafa;
72 | border-radius: 5px;
73 | padding: 0.75rem;
74 | font-size: 1.1rem;
75 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
76 | Bitstream Vera Sans Mono, Courier New, monospace;
77 | }
78 |
79 | .grid {
80 | display: flex;
81 | align-items: center;
82 | justify-content: center;
83 | flex-wrap: wrap;
84 | max-width: 800px;
85 | }
86 |
87 | .card {
88 | margin: 1rem;
89 | padding: 1.5rem;
90 | text-align: left;
91 | color: inherit;
92 | text-decoration: none;
93 | border: 1px solid #eaeaea;
94 | border-radius: 10px;
95 | transition: color 0.15s ease, border-color 0.15s ease;
96 | max-width: 300px;
97 | }
98 |
99 | .card:hover,
100 | .card:focus,
101 | .card:active {
102 | color: #0070f3;
103 | border-color: #0070f3;
104 | }
105 |
106 | .card h2 {
107 | margin: 0 0 1rem 0;
108 | font-size: 1.5rem;
109 | }
110 |
111 | .card p {
112 | margin: 0;
113 | font-size: 1.25rem;
114 | line-height: 1.5;
115 | }
116 |
117 | .logo {
118 | height: 1em;
119 | margin-left: 0.5rem;
120 | }
121 |
122 | @media (max-width: 600px) {
123 | .grid {
124 | width: 100%;
125 | flex-direction: column;
126 | }
127 | }
128 |
129 | @media (prefers-color-scheme: dark) {
130 | .card,
131 | .footer {
132 | border-color: #222;
133 | }
134 | .code {
135 | background: #111;
136 | }
137 | .logo img {
138 | filter: invert(1);
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/app/schemas/core.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 | from typing import Any
3 |
4 | from fastapi import Query
5 | from pydantic import BaseModel, ConfigDict, field_validator
6 | from pydantic.alias_generators import to_camel
7 | from sqlalchemy import desc
8 |
9 | # def to_camel(string: str) -> str:
10 | # return camel.case(string)
11 |
12 |
13 | class BaseSchema(BaseModel):
14 | """全体共通の情報をセットするBaseSchema"""
15 |
16 | # class Configで指定した場合に引数チェックがされないため、ConfigDictを推奨
17 | model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, strict=True)
18 |
19 |
20 | class PagingMeta(BaseSchema):
21 | current_page: int
22 | total_page_count: int
23 | total_data_count: int
24 | per_page: int
25 |
26 |
27 | class PagingQueryIn(BaseSchema):
28 | page: int = Query(1)
29 | per_page: int = Query(30)
30 |
31 | @field_validator("page", mode="before")
32 | def validate_page(cls, v: int) -> int:
33 | return 1 if not v >= 1 else v
34 |
35 | @field_validator("per_page", mode="before")
36 | def validate_per_page(cls, v: int) -> int:
37 | return 30 if not v >= 1 else v
38 |
39 | def get_offset(self) -> int:
40 | return (self.page - 1) * self.per_page if self.page >= 1 and self.per_page >= 1 else 0
41 |
42 | def apply_to_query(self, query: Any) -> Any:
43 | offset = self.get_offset()
44 | return query.offset(offset).limit(self.per_page)
45 |
46 |
47 | class SortDirectionEnum(Enum):
48 | asc: str = "asc"
49 | desc: str = "desc"
50 |
51 |
52 | class SortQueryIn(BaseSchema):
53 | sort_field: Any | None = Query(None)
54 | direction: SortDirectionEnum = Query(SortDirectionEnum.asc)
55 |
56 | def apply_to_query(self, query: Any, order_by_clause: Any | None = None) -> Any:
57 | if not order_by_clause:
58 | return query
59 |
60 | if self.direction == SortDirectionEnum.desc:
61 | return query.order_by(desc(order_by_clause))
62 | else:
63 | return query.order_by(order_by_clause)
64 |
65 |
66 | class FilterQueryIn(BaseSchema):
67 | sort: str = Query(None)
68 | direction: str = Query(None)
69 | start: int | None = Query(None)
70 | end: int | None = Query(None)
71 |
72 | @field_validator("direction", mode="before") # mode=beforeは,v1のpre=Trueと同等
73 | def validate_direction(cls, v: str) -> str:
74 | if not v:
75 | return "asc"
76 | if v not in ["asc", "desc"]:
77 | msg = "asc or desc only"
78 | raise ValueError(msg)
79 | return v
80 |
81 | def validate_allowed_sort_column(self, allowed_columns: list[str]) -> bool:
82 | if not self.sort:
83 | return True
84 | return self.sort in allowed_columns
85 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.231.6/containers/docker-existing-docker-compose
3 | // If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
4 | {
5 | "name": "Existing Docker Compose (Extend)",
6 |
7 | // Update the 'dockerComposeFile' list if you have more compose files or use different names.
8 | // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
9 | "dockerComposeFile": [
10 | "../docker-compose.yml",
11 | // "../docker-compose.es.yml",
12 | ],
13 |
14 | // The 'service' property is the name of the service for the container that VS Code should
15 | // use. Update this value and .devcontainer/docker-compose.yml to the real service name.
16 | "service": "web",
17 |
18 | // The optional 'workspaceFolder' property is the path VS Code should open by default when
19 | // connected. This is typically a file mount in .devcontainer/docker-compose.yml
20 | "workspaceFolder": "/backend",
21 |
22 | // Set *default* container specific settings.json values on container create.
23 | "settings": {
24 | "python.defaultInterpreterPath": "/usr/local/bin/python",
25 | "python.linting.enabled": true,
26 | "python.linting.pylintEnabled": false,
27 | "python.linting.flake8Enabled": true,
28 | "python.linting.flake8Args": ["--ignore=W503"],
29 | "python.linting.mypyEnabled": true,
30 | "python.linting.lintOnSave": true,
31 | "python.formatting.provider": "black",
32 | // "python.analysis.typeCheckingMode": "basic",
33 | "editor.formatOnSave": true,
34 | "mypy.targets": [
35 | "./app"
36 | ]
37 | },
38 |
39 | // Add the IDs of extensions you want installed when the container is created.
40 | "extensions": [
41 | "donjayamanne.python-extension-pack",
42 | "eamodio.gitlens",
43 | "mechatroner.rainbow-csv",
44 | "coenraads.bracket-pair-colorizer-2",
45 | "mikestead.dotenv",
46 | "hediet.vscode-drawio",
47 | "vscode-icons-team.vscode-icons",
48 | "cweijan.vscode-database-client2",
49 | "bungcip.better-toml",
50 | "ms-azuretools.vscode-docker",
51 | "swyphcosmo.spellcheck"
52 | ],
53 |
54 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
55 | // "forwardPorts": [],
56 |
57 | // Uncomment the next line if you want start specific services in your Docker Compose config.
58 | // "runServices": [],
59 |
60 | // Uncomment the next line if you want to keep your containers running after VS Code shuts down.
61 | "shutdownAction": "stopCompose",
62 |
63 | // Uncomment the next line to run commands after the container is created - for example installing curl.
64 | // "postCreateCommand": "apt-get update && apt-get install -y curl",
65 |
66 | // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
67 | // "remoteUser": "vscode"
68 | }
69 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "fastapi-mybest-template"
3 | version = "0.1.0"
4 | description = "Add a short description here"
5 | authors = [
6 | { name = "yoneya.takashi", email = "takashi002013@gmail.com" }
7 | ]
8 | dependencies = [
9 | "uvicorn~=0.22.0",
10 | "sentry-sdk~=1.24.0",
11 | "mysqlclient~=2.1.1",
12 | "ulid-py~=1.1.0",
13 | "python-jose~=3.3.0",
14 | "toml~=0.10.2",
15 | "PyYAML~=6.0",
16 | "python-multipart~=0.0.6",
17 | "python-json-logger~=2.0.7",
18 | "mysql-connector-python~=8.0.33",
19 | "bcrypt~=4.0.1",
20 | "humps~=0.2.2",
21 | "fire~=0.5.0",
22 | "alembic~=1.11.1",
23 | "sqlalchemyseed~=2.0.0",
24 | "pytest-mysql~=2.4.2",
25 | "httpx~=0.24.1",
26 | "SQLALchemy~=2.0.15",
27 | "aiomysql~=0.1.1",
28 | "pytest-asyncio~=0.21.0",
29 | "mangum~=0.17.0",
30 | "passlib~=1.7.4",
31 | "pydantic_settings>=2.0.0",
32 | "fastapi>=0.100.0",
33 | "pydantic[email]>=2.0.2",
34 | "fastapi-debug-toolbar>=0.5.0",
35 | ]
36 | readme = "README.md"
37 | requires-python = ">= 3.10"
38 |
39 | [build-system]
40 | requires = ["hatchling"]
41 | build-backend = "hatchling.build"
42 |
43 | [tool.rye]
44 | managed = true
45 | dev-dependencies = ["ruff~=0.0.270"]
46 | [tool.hatch.metadata]
47 | allow-direct-references = true
48 |
49 | [tool.black]
50 | line-length = 120
51 |
52 | [tool.ruff]
53 | target-version = "py310"
54 | select = ["ALL"]
55 | exclude = [".venv", "alembic"]
56 | ignore = [
57 | "G004", # `logging-f-string`
58 | "PLC1901", # compare-to-empty-string
59 | "PLR2004", # magic-value-comparison
60 | "ANN101", # missing-type-self
61 | "ANN102", # missing-type-cls
62 | "ANN002", # missing-type-args
63 | "ANN003", # missing-type-kwargs
64 | "ANN401", # any-type
65 | "ERA", # commented-out-code
66 | "ARG002", # unused-method-argument
67 | "INP001", # implicit-namespace-package
68 | "PGH004", # blanket-noqa
69 | "B008", # Dependsで使用するため
70 | "A002", # builtin-argument-shadowing
71 | "A003", # builtin-attribute-shadowing
72 | "PLR0913", # too-many-arguments
73 | "RSE", # flake8-raise
74 | "D", # pydocstyle
75 | "C90", # mccabe
76 | "T20", # flake8-print
77 | "SLF", # flake8-self
78 | "BLE", # flake8-blind-except
79 | "FBT", # flake8-boolean-trap
80 | "TRY", # tryceratops
81 | "COM", # flake8-commas
82 | "S", # flake8-bandit
83 | "EM",#flake8-errmsg
84 | "EXE", # flake8-executable
85 | "ICN", # flake8-import-conventions
86 | "RET",#flake8-return
87 | "SIM",#flake8-simplify
88 | "TCH", # flake8-type-checking
89 | "PTH", #pathlibを使わないコードが多いので、除外する
90 | "ISC", #flake8-implicit-str-concat
91 | "N", # pep8-naming
92 | "PT", # flake8-pytest-style
93 | "RUF012", # pydanticとの相性が悪いので一旦無効化
94 | "TD002",
95 | "TD003",
96 | "FIX002" # TODOの記述がエラーになるため
97 | ]
98 | line-length = 120
99 |
100 | [tool.ruff.per-file-ignores]
101 | "__init__.py" = ["F401"]
102 |
--------------------------------------------------------------------------------
/app/core/auth.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta, timezone
2 | from typing import Any
3 |
4 | from fastapi import Depends, status
5 | from fastapi.security import OAuth2PasswordBearer, SecurityScopes
6 | from jose import jwt
7 | from jose.exceptions import JWTError
8 | from passlib.context import CryptContext
9 | from pydantic import ValidationError
10 | from sqlalchemy.ext.asyncio import AsyncSession
11 |
12 | from app import crud_v2, models, schemas
13 | from app.exceptions.core import APIException
14 | from app.exceptions.error_messages import ErrorMessage
15 |
16 | from .config import settings
17 | from .database import get_async_db
18 | from .logger import get_logger
19 |
20 | logger = get_logger(__name__)
21 |
22 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
23 | ALGORITHM = "HS256"
24 | reusable_oauth2 = OAuth2PasswordBearer(
25 | tokenUrl=f"{settings.API_GATEWAY_STAGE_PATH}/auth/login",
26 | auto_error=False,
27 | )
28 |
29 |
30 | def create_access_token(
31 | subject: str | Any,
32 | expires_delta: timedelta | None = None,
33 | ) -> str:
34 | if expires_delta:
35 | expire = datetime.now(tz=timezone.utc) + expires_delta
36 | else:
37 | expire = datetime.now(tz=timezone.utc) + timedelta(
38 | minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
39 | )
40 | to_encode = {"exp": expire, "sub": str(subject)}
41 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
42 | return encoded_jwt
43 |
44 |
45 | def verify_password(plain_password: str, hashed_password: str) -> bool:
46 | return pwd_context.verify(plain_password, hashed_password)
47 |
48 |
49 | def get_password_hash(password: str) -> str:
50 | return pwd_context.hash(password)
51 |
52 |
53 | async def get_current_user(
54 | security_scopes: SecurityScopes,
55 | db: AsyncSession = Depends(get_async_db),
56 | token: str = Depends(reusable_oauth2),
57 | ) -> models.User:
58 | if not token:
59 | raise APIException(ErrorMessage.CouldNotValidateCredentials)
60 |
61 | try:
62 | payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
63 | token_data = schemas.TokenPayload(**payload)
64 | except (JWTError, ValidationError):
65 | raise APIException(ErrorMessage.CouldNotValidateCredentials) from None
66 | user = await crud_v2.user.get_db_obj_by_id(db, id=token_data.sub)
67 | if not user:
68 | raise APIException(ErrorMessage.NOT_FOUND("User"))
69 | user_scopes = user.scopes.split(",") if user.scopes else []
70 | for scope in security_scopes.scopes:
71 | if scope not in user_scopes:
72 | raise APIException(
73 | status_code=status.HTTP_401_UNAUTHORIZED,
74 | error=ErrorMessage.PERMISSION_ERROR,
75 | )
76 | return user
77 |
78 |
79 | # def get_current_active_user(
80 | # ) -> models.User:
81 | # if not crud.user.is_active(current_user):
82 |
83 |
84 | # def get_current_active_superuser(
85 | # ) -> models.User:
86 | # if not crud.user.is_superuser(current_user):
87 | # raise HTTPException(
88 |
--------------------------------------------------------------------------------
/app/api/endpoints/todos.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter, Depends, status
2 | from sqlalchemy.ext.asyncio import AsyncSession
3 |
4 | from app import crud_v2, schemas
5 | from app.core.database import get_async_db
6 | from app.core.logger import get_logger
7 | from app.exceptions.core import APIException
8 | from app.exceptions.error_messages import ErrorMessage
9 | from app.schemas.core import PagingQueryIn
10 |
11 | logger = get_logger(__name__)
12 |
13 | router = APIRouter()
14 |
15 |
16 | @router.get("/{id}", operation_id="get_todo_by_id")
17 | async def get_job(
18 | id: str,
19 | include_deleted: bool = False,
20 | db: AsyncSession = Depends(get_async_db),
21 | ) -> schemas.TodoResponse:
22 | todo = await crud_v2.todo.get_db_obj_by_id(
23 | db,
24 | id=id,
25 | include_deleted=include_deleted,
26 | )
27 | if not todo:
28 | raise APIException(ErrorMessage.ID_NOT_FOUND)
29 | return todo
30 |
31 |
32 | @router.get("", operation_id="get_paged_todos")
33 | async def get_paged_todos(
34 | q: str | None = None,
35 | paging_query_in: PagingQueryIn = Depends(),
36 | sort_query_in: schemas.TodoSortQueryIn = Depends(),
37 | include_deleted: bool = False,
38 | db: AsyncSession = Depends(get_async_db),
39 | ) -> schemas.TodosPagedResponse:
40 | return await crud_v2.todo.get_paged_list(
41 | db,
42 | q=q,
43 | paging_query_in=paging_query_in,
44 | sort_query_in=sort_query_in,
45 | include_deleted=include_deleted,
46 | )
47 |
48 |
49 | @router.post("", operation_id="create_todo")
50 | async def create_todo(
51 | data_in: schemas.TodoCreate,
52 | db: AsyncSession = Depends(get_async_db),
53 | ) -> schemas.TodoResponse:
54 | return await crud_v2.todo.create(db, data_in)
55 |
56 |
57 | @router.patch("/{id}", operation_id="update_todo")
58 | async def update_todo(
59 | id: str,
60 | data_in: schemas.TodoUpdate,
61 | db: AsyncSession = Depends(get_async_db),
62 | ) -> schemas.TodoResponse:
63 | todo = await crud_v2.todo.get_db_obj_by_id(db, id=id)
64 | if not todo:
65 | raise APIException(ErrorMessage.ID_NOT_FOUND)
66 |
67 | return await crud_v2.todo.update(db, db_obj=todo, update_schema=data_in)
68 |
69 |
70 | @router.post("/{id}/tags", operation_id="add_tags_to_todo")
71 | async def add_tags_to_todo(
72 | id: str,
73 | tags_in: list[schemas.TagCreate],
74 | db: AsyncSession = Depends(get_async_db),
75 | ) -> schemas.TodoResponse:
76 | todo = await crud_v2.todo.get_db_obj_by_id(db, id=id)
77 | if not todo:
78 | raise APIException(ErrorMessage.ID_NOT_FOUND)
79 | return await crud_v2.todo.add_tags_to_todo(db, todo=todo, tags_in=tags_in)
80 |
81 |
82 | @router.delete(
83 | "/{id}",
84 | status_code=status.HTTP_204_NO_CONTENT,
85 | operation_id="delete_todo",
86 | )
87 | async def delete_todo(
88 | id: str,
89 | db: AsyncSession = Depends(get_async_db),
90 | ) -> None:
91 | todo = await crud_v2.todo.get_db_obj_by_id(db, id=id)
92 | if not todo:
93 | raise APIException(ErrorMessage.ID_NOT_FOUND)
94 | await crud_v2.todo.delete(db, db_obj=todo)
95 |
--------------------------------------------------------------------------------
/alembic.ini:
--------------------------------------------------------------------------------
1 | # A generic, single database configuration.
2 |
3 | [alembic]
4 | # path to migration scripts
5 | script_location = alembic
6 |
7 | # template used to generate migration files
8 | file_template = %%(year)d%%(month).2d%%(day).2d-%%(hour).2d%%(minute).2d_%%(slug)s
9 |
10 | # sys.path path, will be prepended to sys.path if present.
11 | # defaults to the current working directory.
12 | prepend_sys_path = .
13 |
14 | # timezone to use when rendering the date within the migration file
15 | # as well as the filename.
16 | # If specified, requires the python-dateutil library that can be
17 | # installed by adding `alembic[tz]` to the pip requirements
18 | # string value is passed to dateutil.tz.gettz()
19 | # leave blank for localtime
20 | # timezone =
21 |
22 | # max length of characters to apply to the
23 | # "slug" field
24 | # truncate_slug_length = 40
25 |
26 | # set to 'true' to run the environment during
27 | # the 'revision' command, regardless of autogenerate
28 | # revision_environment = false
29 |
30 | # set to 'true' to allow .pyc and .pyo files without
31 | # a source .py file to be detected as revisions in the
32 | # versions/ directory
33 | # sourceless = false
34 |
35 | # version location specification; This defaults
36 | # to alembic/versions. When using multiple version
37 | # directories, initial revisions must be specified with --version-path.
38 | # The path separator used here should be the separator specified by "version_path_separator" below.
39 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
40 |
41 | # version path separator; As mentioned above, this is the character used to split
42 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
43 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
44 | # Valid values for version_path_separator are:
45 | #
46 | # version_path_separator = :
47 | # version_path_separator = ;
48 | # version_path_separator = space
49 | version_path_separator = os
50 |
51 | # the output encoding used when revision files
52 | # are written from script.py.mako
53 | # output_encoding = utf-8
54 |
55 | ; sqlalchemy.url = driver://user:pass@localhost/dbname
56 |
57 | [post_write_hooks]
58 | # post_write_hooks defines scripts or Python functions that are run
59 | # on newly generated revision scripts. See the documentation for further
60 | # detail and examples
61 |
62 | # format using "black" - use the console_scripts runner, against the "black" entrypoint
63 | # hooks = black
64 | # black.type = console_scripts
65 | # black.entrypoint = black
66 | # black.options = -l 79 REVISION_SCRIPT_FILENAME
67 |
68 | # Logging configuration
69 | [loggers]
70 | keys = root,sqlalchemy,alembic
71 |
72 | [handlers]
73 | keys = console
74 |
75 | [formatters]
76 | keys = generic
77 |
78 | [logger_root]
79 | level = WARN
80 | handlers = console
81 | qualname =
82 |
83 | [logger_sqlalchemy]
84 | level = WARN
85 | handlers =
86 | qualname = sqlalchemy.engine
87 |
88 | [logger_alembic]
89 | level = INFO
90 | handlers =
91 | qualname = alembic
92 |
93 | [handler_console]
94 | class = StreamHandler
95 | args = (sys.stderr,)
96 | level = NOTSET
97 | formatter = generic
98 |
99 | [formatter_generic]
100 | format = %(levelname)-5.5s [%(name)s] %(message)s
101 | datefmt = %H:%M:%S
102 |
--------------------------------------------------------------------------------
/app/core/database.py:
--------------------------------------------------------------------------------
1 | from collections.abc import AsyncGenerator, Generator
2 |
3 | from debug_toolbar.panels.sqlalchemy import SQLAlchemyPanel as BasePanel
4 | from fastapi import Request
5 | from sqlalchemy import MetaData, create_engine
6 | from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
7 | from sqlalchemy.orm import Session, sessionmaker
8 | from sqlalchemy.sql import text
9 |
10 | from app.core.config import settings
11 | from app.core.logger import get_logger
12 |
13 | logger = get_logger(__name__)
14 |
15 |
16 | try:
17 | engine = create_engine(
18 | settings.get_database_url(),
19 | connect_args={"auth_plugin": "mysql_native_password"},
20 | pool_pre_ping=True,
21 | echo=False,
22 | future=True,
23 | )
24 | session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine)
25 | except Exception as e:
26 | logger.error(f"DB connection error. detail={e}")
27 |
28 |
29 | try:
30 | async_engine = create_async_engine(
31 | settings.get_database_url(is_async=True),
32 | connect_args={"auth_plugin": "mysql_native_password"},
33 | pool_pre_ping=True,
34 | echo=False,
35 | future=True,
36 | )
37 | async_session_factory = sessionmaker(
38 | autocommit=False,
39 | autoflush=False,
40 | bind=async_engine,
41 | class_=AsyncSession,
42 | )
43 | except Exception as e:
44 | logger.error(f"DB connection error. detail={e}")
45 |
46 |
47 | def get_db() -> Generator[Session, None, None]:
48 | """endpointからアクセス時に、Dependで呼び出しdbセッションを生成する
49 | エラーがなければ、commitする
50 | エラー時はrollbackし、いずれの場合も最終的にcloseする.
51 | """
52 | db = None
53 | try:
54 | db = session_factory()
55 | yield db
56 | db.commit()
57 | except Exception:
58 | if db:
59 | db.rollback()
60 | finally:
61 | if db:
62 | db.close()
63 |
64 |
65 | async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
66 | """async用のdb-sessionの作成."""
67 | async with async_session_factory() as db:
68 | try:
69 | yield db
70 | await db.commit()
71 | except Exception:
72 | await db.rollback()
73 | finally:
74 | await db.close()
75 |
76 |
77 | def drop_all_tables() -> None:
78 | logger.info("start: drop_all_tables")
79 | """
80 | 全てのテーブルおよび型、Roleなどを削除して、初期状態に戻す(開発環境専用)
81 | """
82 | if settings.ENV != "local":
83 | # ローカル環境でしか動作させない
84 | logger.info("drop_all_table() is ENV local only.")
85 | return
86 |
87 | metadata = MetaData()
88 | metadata.reflect(bind=engine)
89 |
90 | with engine.connect() as conn:
91 | # 外部キーの制御を一時的に無効化
92 | conn.execute(text("SET FOREIGN_KEY_CHECKS = 0"))
93 |
94 | # 全テーブルを削除
95 | for table in metadata.tables:
96 | conn.execute(text(f"DROP TABLE {table} CASCADE"))
97 |
98 | # 外部キーの制御を有効化
99 | conn.execute(text("SET FOREIGN_KEY_CHECKS = 1"))
100 | logger.info("end: drop_all_tables")
101 |
102 |
103 | if settings.DEBUG:
104 |
105 | class SQLAlchemyPanel(BasePanel):
106 | async def add_engines(self, request: Request) -> None:
107 | self.engines.add(async_engine.sync_engine)
108 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib64/
18 | parts/
19 | sdist/
20 | var/
21 | wheels/
22 | share/python-wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .nox/
42 | .coverage
43 | .coverage.*
44 | .cache
45 | nosetests.xml
46 | coverage.xml
47 | *.cover
48 | *.py,cover
49 | .hypothesis/
50 | .pytest_cache/
51 | cover/
52 |
53 | # Translations
54 | *.mo
55 | *.pot
56 |
57 | # Django stuff:
58 | *.log
59 | local_settings.py
60 | db.sqlite3
61 | db.sqlite3-journal
62 |
63 | # Flask stuff:
64 | instance/
65 | .webassets-cache
66 |
67 | # Scrapy stuff:
68 | .scrapy
69 |
70 | # Sphinx documentation
71 | docs/_build/
72 |
73 | # PyBuilder
74 | .pybuilder/
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | # For a library or package, you might want to ignore these files since the code is
86 | # intended to run in multiple environments; otherwise, check them in:
87 | # .python-version
88 |
89 | # pipenv
90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
93 | # install all needed dependencies.
94 | #Pipfile.lock
95 |
96 | # poetry
97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
98 | # This is especially recommended for binary packages to ensure reproducibility, and is more
99 | # commonly ignored for libraries.
100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
101 | #poetry.lock
102 |
103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
104 | __pypackages__/
105 |
106 | # Celery stuff
107 | celerybeat-schedule
108 | celerybeat.pid
109 |
110 | # SageMath parsed files
111 | *.sage.py
112 |
113 | # Environments
114 | .env
115 | .venv
116 | env/
117 | venv/
118 | ENV/
119 | env.bak/
120 | venv.bak/
121 |
122 | # Spyder project settings
123 | .spyderproject
124 | .spyproject
125 |
126 | # Rope project settings
127 | .ropeproject
128 |
129 | # mkdocs documentation
130 | /site
131 |
132 | # mypy
133 | .mypy_cache/
134 | .dmypy.json
135 | dmypy.json
136 |
137 | # Pyre type checker
138 | .pyre/
139 |
140 | # pytype static type analyzer
141 | .pytype/
142 |
143 | # Cython debug symbols
144 | cython_debug/
145 |
146 | # PyCharm
147 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
148 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
149 | # and can be added to the global gitignore or merged into this file. For a more nuclear
150 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
151 | #.idea/
152 | .history/
153 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/configuration.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | /**
4 | * FastAPI Sample
5 | * Apps link
/docs
/admin/docs
/other/docs
6 | *
7 | * The version of the OpenAPI document: 0.0.1
8 | *
9 | *
10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
11 | * https://openapi-generator.tech
12 | * Do not edit the class manually.
13 | */
14 |
15 | export interface ConfigurationParameters {
16 | apiKey?:
17 | | string
18 | | Promise
19 | | ((name: string) => string)
20 | | ((name: string) => Promise);
21 | username?: string;
22 | password?: string;
23 | accessToken?:
24 | | string
25 | | Promise
26 | | ((name?: string, scopes?: string[]) => string)
27 | | ((name?: string, scopes?: string[]) => Promise);
28 | basePath?: string;
29 | baseOptions?: any;
30 | formDataCtor?: new () => any;
31 | }
32 |
33 | export class Configuration {
34 | /**
35 | * parameter for apiKey security
36 | * @param name security name
37 | * @memberof Configuration
38 | */
39 | apiKey?:
40 | | string
41 | | Promise
42 | | ((name: string) => string)
43 | | ((name: string) => Promise);
44 | /**
45 | * parameter for basic security
46 | *
47 | * @type {string}
48 | * @memberof Configuration
49 | */
50 | username?: string;
51 | /**
52 | * parameter for basic security
53 | *
54 | * @type {string}
55 | * @memberof Configuration
56 | */
57 | password?: string;
58 | /**
59 | * parameter for oauth2 security
60 | * @param name security name
61 | * @param scopes oauth2 scope
62 | * @memberof Configuration
63 | */
64 | accessToken?:
65 | | string
66 | | Promise
67 | | ((name?: string, scopes?: string[]) => string)
68 | | ((name?: string, scopes?: string[]) => Promise);
69 | /**
70 | * override base path
71 | *
72 | * @type {string}
73 | * @memberof Configuration
74 | */
75 | basePath?: string;
76 | /**
77 | * base options for axios calls
78 | *
79 | * @type {any}
80 | * @memberof Configuration
81 | */
82 | baseOptions?: any;
83 | /**
84 | * The FormData constructor that will be used to create multipart form data
85 | * requests. You can inject this here so that execution environments that
86 | * do not support the FormData class can still run the generated client.
87 | *
88 | * @type {new () => FormData}
89 | */
90 | formDataCtor?: new () => any;
91 |
92 | constructor(param: ConfigurationParameters = {}) {
93 | this.apiKey = param.apiKey;
94 | this.username = param.username;
95 | this.password = param.password;
96 | this.accessToken = param.accessToken;
97 | this.basePath = param.basePath;
98 | this.baseOptions = param.baseOptions;
99 | this.formDataCtor = param.formDataCtor;
100 | }
101 |
102 | /**
103 | * Check if the given MIME is a JSON MIME.
104 | * JSON MIME examples:
105 | * application/json
106 | * application/json; charset=UTF8
107 | * APPLICATION/JSON
108 | * application/vnd.company+json
109 | * @param mime - MIME (Multipurpose Internet Mail Extensions)
110 | * @return True if the given MIME is JSON, false otherwise.
111 | */
112 | public isJsonMime(mime: string): boolean {
113 | const jsonMime: RegExp = new RegExp(
114 | "^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$",
115 | "i",
116 | );
117 | return (
118 | mime !== null &&
119 | (jsonMime.test(mime) ||
120 | mime.toLowerCase() === "application/json-patch+json")
121 | );
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/.github/workflows/aws.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build and push a new container image to Amazon ECR,
2 | # and then will deploy a new task definition to Amazon ECS, when there is a push to the master branch.
3 | #
4 | # To use this workflow, you will need to complete the following set-up steps:
5 | #
6 | # 1. Create an ECR repository to store your images.
7 | # For example: `aws ecr create-repository --repository-name my-ecr-repo --region us-east-2`.
8 | # Replace the value of the `ECR_REPOSITORY` environment variable in the workflow below with your repository's name.
9 | # Replace the value of the `AWS_REGION` environment variable in the workflow below with your repository's region.
10 | #
11 | # 2. Create an ECS task definition, an ECS cluster, and an ECS service.
12 | # For example, follow the Getting Started guide on the ECS console:
13 | # https://us-east-2.console.aws.amazon.com/ecs/home?region=us-east-2#/firstRun
14 | # Replace the value of the `ECS_SERVICE` environment variable in the workflow below with the name you set for the Amazon ECS service.
15 | # Replace the value of the `ECS_CLUSTER` environment variable in the workflow below with the name you set for the cluster.
16 | #
17 | # 3. Store your ECS task definition as a JSON file in your repository.
18 | # The format should follow the output of `aws ecs register-task-definition --generate-cli-skeleton`.
19 | # Replace the value of the `ECS_TASK_DEFINITION` environment variable in the workflow below with the path to the JSON file.
20 | # Replace the value of the `CONTAINER_NAME` environment variable in the workflow below with the name of the container
21 | # in the `containerDefinitions` section of the task definition.
22 | #
23 | # 4. Store an IAM user access key in GitHub Actions secrets named `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`.
24 | # See the documentation for each action used below for the recommended IAM policies for this IAM user,
25 | # and best practices on handling the access key credentials.
26 |
27 | name: Deploy to Amazon ECS
28 |
29 | on:
30 | push:
31 | branches:
32 | - auto_deploy
33 |
34 | env:
35 | AWS_REGION: us-west-1 # set this to your preferred AWS region, e.g. us-west-1
36 | ECR_REPOSITORY: fastapi-sample-backend # set this to your Amazon ECR repository name
37 | ECS_SERVICE: fastapi-sample-backend # set this to your Amazon ECS service name
38 | ECS_CLUSTER: fastapi-sample-cluster # set this to your Amazon ECS cluster name
39 | ECS_TASK_DEFINITION: .aws/ecs-task-definition.json # set this to the path to your Amazon ECS task definition
40 | # file, e.g. .aws/task-definition.json
41 | CONTAINER_NAME: fastapi-sample-backend # set this to the name of the container in the
42 | # containerDefinitions section of your task definition
43 |
44 | jobs:
45 | deploy:
46 | name: Deploy
47 | runs-on: ubuntu-latest
48 | environment: production
49 |
50 | steps:
51 | - name: Checkout
52 | uses: actions/checkout@v3
53 |
54 | - name: Configure AWS credentials
55 | uses: aws-actions/configure-aws-credentials@v1
56 | with:
57 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
58 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
59 | aws-region: ${{ env.AWS_REGION }}
60 |
61 | - name: Login to Amazon ECR
62 | id: login-ecr
63 | uses: aws-actions/amazon-ecr-login@v1
64 |
65 | - name: Build, tag, and push image to Amazon ECR
66 | id: build-image
67 | env:
68 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
69 | IMAGE_TAG: ${{ github.sha }}
70 | run: |
71 | # Build a docker container and
72 | # push it to ECR so that it can
73 | # be deployed to ECS.
74 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
75 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
76 | echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
77 |
78 | - name: Fill in the new image ID in the Amazon ECS task definition
79 | id: task-def
80 | uses: aws-actions/amazon-ecs-render-task-definition@v1
81 | with:
82 | task-definition: ${{ env.ECS_TASK_DEFINITION }}
83 | container-name: ${{ env.CONTAINER_NAME }}
84 | image: ${{ steps.build-image.outputs.image }}
85 |
86 | - name: Deploy Amazon ECS task definition
87 | uses: aws-actions/amazon-ecs-deploy-task-definition@v1
88 | with:
89 | task-definition: ${{ steps.task-def.outputs.task-definition }}
90 | service: ${{ env.ECS_SERVICE }}
91 | cluster: ${{ env.ECS_CLUSTER }}
92 | wait-for-service-stability: true
93 |
--------------------------------------------------------------------------------
/alembic/versions/20230131-0237_.py:
--------------------------------------------------------------------------------
1 | """empty message.
2 |
3 | Revision ID: 6c689d530cbe
4 | Revises:
5 | Create Date: 2023-01-31 02:37:07.077007
6 |
7 | """
8 | import sqlalchemy as sa
9 |
10 | from alembic import op
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = "6c689d530cbe"
14 | down_revision = None
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.create_table(
22 | "tags",
23 | sa.Column("name", sa.String(length=100), nullable=True),
24 | sa.Column("id", sa.String(length=32), nullable=False),
25 | sa.Column(
26 | "created_at",
27 | sa.DateTime(),
28 | server_default=sa.text("CURRENT_TIMESTAMP"),
29 | nullable=False,
30 | ),
31 | sa.Column("updated_at", sa.DateTime(), nullable=False),
32 | sa.Column("deleted_at", sa.DateTime(), nullable=True),
33 | sa.PrimaryKeyConstraint("id"),
34 | )
35 | op.create_index(op.f("ix_tags_name"), "tags", ["name"], unique=True)
36 | op.create_table(
37 | "todos",
38 | sa.Column("title", sa.String(length=100), nullable=True),
39 | sa.Column("description", sa.Text(), nullable=True),
40 | sa.Column("completed_at", sa.DateTime(), nullable=True),
41 | sa.Column("id", sa.String(length=32), nullable=False),
42 | sa.Column(
43 | "created_at",
44 | sa.DateTime(),
45 | server_default=sa.text("CURRENT_TIMESTAMP"),
46 | nullable=False,
47 | ),
48 | sa.Column("updated_at", sa.DateTime(), nullable=False),
49 | sa.Column("deleted_at", sa.DateTime(), nullable=True),
50 | sa.PrimaryKeyConstraint("id"),
51 | )
52 | op.create_index(op.f("ix_todos_title"), "todos", ["title"], unique=False)
53 | op.create_table(
54 | "users",
55 | sa.Column("full_name", sa.String(length=64), nullable=True),
56 | sa.Column("email", sa.String(length=200), nullable=False),
57 | sa.Column("email_verified", sa.Boolean(), server_default="0", nullable=False),
58 | sa.Column("hashed_password", sa.Text(), nullable=False),
59 | sa.Column("scopes", sa.Text(), nullable=True),
60 | sa.Column("id", sa.String(length=32), nullable=False),
61 | sa.Column(
62 | "created_at",
63 | sa.DateTime(),
64 | server_default=sa.text("CURRENT_TIMESTAMP"),
65 | nullable=False,
66 | ),
67 | sa.Column("updated_at", sa.DateTime(), nullable=False),
68 | sa.Column("deleted_at", sa.DateTime(), nullable=True),
69 | sa.PrimaryKeyConstraint("id"),
70 | )
71 | op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
72 | op.create_index(op.f("ix_users_full_name"), "users", ["full_name"], unique=False)
73 | op.create_table(
74 | "todos_tags",
75 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
76 | sa.Column("todo_id", sa.String(length=32), nullable=True),
77 | sa.Column("tag_id", sa.String(length=32), nullable=True),
78 | sa.Column(
79 | "created_at",
80 | sa.DateTime(),
81 | server_default=sa.text("CURRENT_TIMESTAMP"),
82 | nullable=False,
83 | ),
84 | sa.Column("updated_at", sa.DateTime(), nullable=False),
85 | sa.ForeignKeyConstraint(
86 | ["tag_id"],
87 | ["tags.id"],
88 | ),
89 | sa.ForeignKeyConstraint(
90 | ["todo_id"],
91 | ["todos.id"],
92 | ),
93 | sa.PrimaryKeyConstraint("id"),
94 | sa.UniqueConstraint("todo_id", "tag_id", name="ix_todos_tags_todo_id_tag_id"),
95 | )
96 | op.create_index(
97 | op.f("ix_todos_tags_tag_id"),
98 | "todos_tags",
99 | ["tag_id"],
100 | unique=False,
101 | )
102 | op.create_index(
103 | op.f("ix_todos_tags_todo_id"),
104 | "todos_tags",
105 | ["todo_id"],
106 | unique=False,
107 | )
108 | # ### end Alembic commands ###
109 |
110 |
111 | def downgrade():
112 | # ### commands auto generated by Alembic - please adjust! ###
113 | op.drop_index(op.f("ix_todos_tags_todo_id"), table_name="todos_tags")
114 | op.drop_index(op.f("ix_todos_tags_tag_id"), table_name="todos_tags")
115 | op.drop_table("todos_tags")
116 | op.drop_index(op.f("ix_users_full_name"), table_name="users")
117 | op.drop_index(op.f("ix_users_email"), table_name="users")
118 | op.drop_table("users")
119 | op.drop_index(op.f("ix_todos_title"), table_name="todos")
120 | op.drop_table("todos")
121 | op.drop_index(op.f("ix_tags_name"), table_name="tags")
122 | op.drop_table("tags")
123 | # ### end Alembic commands ###
124 |
--------------------------------------------------------------------------------
/frontend_sample/src/api_clients/common.ts:
--------------------------------------------------------------------------------
1 | /* tslint:disable */
2 | /* eslint-disable */
3 | /**
4 | * FastAPI Sample
5 | * Apps link
/docs
/admin/docs
/other/docs
6 | *
7 | * The version of the OpenAPI document: 0.0.1
8 | *
9 | *
10 | * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
11 | * https://openapi-generator.tech
12 | * Do not edit the class manually.
13 | */
14 |
15 | import type { Configuration } from "./configuration";
16 | import type { RequestArgs } from "./base";
17 | import type { AxiosInstance, AxiosResponse } from "axios";
18 | import { RequiredError } from "./base";
19 |
20 | /**
21 | *
22 | * @export
23 | */
24 | export const DUMMY_BASE_URL = "https://example.com";
25 |
26 | /**
27 | *
28 | * @throws {RequiredError}
29 | * @export
30 | */
31 | export const assertParamExists = function (
32 | functionName: string,
33 | paramName: string,
34 | paramValue: unknown,
35 | ) {
36 | if (paramValue === null || paramValue === undefined) {
37 | throw new RequiredError(
38 | paramName,
39 | `Required parameter ${paramName} was null or undefined when calling ${functionName}.`,
40 | );
41 | }
42 | };
43 |
44 | /**
45 | *
46 | * @export
47 | */
48 | export const setApiKeyToObject = async function (
49 | object: any,
50 | keyParamName: string,
51 | configuration?: Configuration,
52 | ) {
53 | if (configuration && configuration.apiKey) {
54 | const localVarApiKeyValue =
55 | typeof configuration.apiKey === "function"
56 | ? await configuration.apiKey(keyParamName)
57 | : await configuration.apiKey;
58 | object[keyParamName] = localVarApiKeyValue;
59 | }
60 | };
61 |
62 | /**
63 | *
64 | * @export
65 | */
66 | export const setBasicAuthToObject = function (
67 | object: any,
68 | configuration?: Configuration,
69 | ) {
70 | if (configuration && (configuration.username || configuration.password)) {
71 | object["auth"] = {
72 | username: configuration.username,
73 | password: configuration.password,
74 | };
75 | }
76 | };
77 |
78 | /**
79 | *
80 | * @export
81 | */
82 | export const setBearerAuthToObject = async function (
83 | object: any,
84 | configuration?: Configuration,
85 | ) {
86 | if (configuration && configuration.accessToken) {
87 | const accessToken =
88 | typeof configuration.accessToken === "function"
89 | ? await configuration.accessToken()
90 | : await configuration.accessToken;
91 | object["Authorization"] = "Bearer " + accessToken;
92 | }
93 | };
94 |
95 | /**
96 | *
97 | * @export
98 | */
99 | export const setOAuthToObject = async function (
100 | object: any,
101 | name: string,
102 | scopes: string[],
103 | configuration?: Configuration,
104 | ) {
105 | if (configuration && configuration.accessToken) {
106 | const localVarAccessTokenValue =
107 | typeof configuration.accessToken === "function"
108 | ? await configuration.accessToken(name, scopes)
109 | : await configuration.accessToken;
110 | object["Authorization"] = "Bearer " + localVarAccessTokenValue;
111 | }
112 | };
113 |
114 | function setFlattenedQueryParams(
115 | urlSearchParams: URLSearchParams,
116 | parameter: any,
117 | key: string = "",
118 | ): void {
119 | if (parameter == null) return;
120 | if (typeof parameter === "object") {
121 | if (Array.isArray(parameter)) {
122 | (parameter as any[]).forEach((item) =>
123 | setFlattenedQueryParams(urlSearchParams, item, key),
124 | );
125 | } else {
126 | Object.keys(parameter).forEach((currentKey) =>
127 | setFlattenedQueryParams(
128 | urlSearchParams,
129 | parameter[currentKey],
130 | `${key}${key !== "" ? "." : ""}${currentKey}`,
131 | ),
132 | );
133 | }
134 | } else {
135 | if (urlSearchParams.has(key)) {
136 | urlSearchParams.append(key, parameter);
137 | } else {
138 | urlSearchParams.set(key, parameter);
139 | }
140 | }
141 | }
142 |
143 | /**
144 | *
145 | * @export
146 | */
147 | export const setSearchParams = function (url: URL, ...objects: any[]) {
148 | const searchParams = new URLSearchParams(url.search);
149 | setFlattenedQueryParams(searchParams, objects);
150 | url.search = searchParams.toString();
151 | };
152 |
153 | /**
154 | *
155 | * @export
156 | */
157 | export const serializeDataIfNeeded = function (
158 | value: any,
159 | requestOptions: any,
160 | configuration?: Configuration,
161 | ) {
162 | const nonString = typeof value !== "string";
163 | const needsSerialization =
164 | nonString && configuration && configuration.isJsonMime
165 | ? configuration.isJsonMime(requestOptions.headers["Content-Type"])
166 | : nonString;
167 | return needsSerialization
168 | ? JSON.stringify(value !== undefined ? value : {})
169 | : value || "";
170 | };
171 |
172 | /**
173 | *
174 | * @export
175 | */
176 | export const toPathString = function (url: URL) {
177 | return url.pathname + url.search + url.hash;
178 | };
179 |
180 | /**
181 | *
182 | * @export
183 | */
184 | export const createRequestFunction = function (
185 | axiosArgs: RequestArgs,
186 | globalAxios: AxiosInstance,
187 | BASE_PATH: string,
188 | configuration?: Configuration,
189 | ) {
190 | return >(
191 | axios: AxiosInstance = globalAxios,
192 | basePath: string = BASE_PATH,
193 | ) => {
194 | const axiosRequestArgs = {
195 | ...axiosArgs.options,
196 | url: (configuration?.basePath || basePath) + axiosArgs.url,
197 | };
198 | return axios.request(axiosRequestArgs);
199 | };
200 | };
201 |
--------------------------------------------------------------------------------
/tests/todos/test_todos.py:
--------------------------------------------------------------------------------
1 | from typing import Any
2 |
3 | import pytest
4 | from httpx import AsyncClient
5 | from starlette import status
6 |
7 | from app.schemas.core import PagingMeta
8 | from app.schemas.todo import TodoCreate, TodoUpdate
9 | from tests.base import (
10 | assert_create,
11 | assert_get_by_id,
12 | assert_get_paged_list,
13 | assert_update,
14 | )
15 |
16 |
17 | @pytest.mark.asyncio
18 | class TestTodos:
19 | ENDPOINT_URI = "/todos"
20 |
21 | """create
22 | """
23 |
24 | @pytest.mark.parametrize(
25 | ["data_in", "expected_status", "expected_data", "expected_error"],
26 | [
27 | pytest.param(
28 | TodoCreate(title="test-create-title", description="test-create-description").model_dump(by_alias=True),
29 | status.HTTP_200_OK,
30 | {"title": "test-create-title", "description": "test-create-description"},
31 | None,
32 | id="success",
33 | )
34 | ],
35 | )
36 | async def test_create(
37 | self,
38 | authed_client: AsyncClient,
39 | data_in: dict,
40 | expected_status: int,
41 | expected_data: dict | None,
42 | expected_error: dict | None,
43 | ) -> None:
44 | await assert_create(
45 | self.ENDPOINT_URI,
46 | client=authed_client,
47 | data_in=data_in,
48 | expected_status=expected_status,
49 | expected_data=expected_data,
50 | )
51 |
52 | """update
53 | """
54 |
55 | @pytest.mark.parametrize(
56 | [
57 | "id",
58 | "data_in",
59 | "expected_status",
60 | "expected_data",
61 | "expected_error",
62 | ],
63 | [
64 | pytest.param(
65 | "1",
66 | TodoUpdate(title="test-update-title", description="test-update-description").dict(by_alias=True),
67 | status.HTTP_200_OK,
68 | {"title": "test-update-title", "description": "test-update-description"},
69 | None,
70 | id="success",
71 | ),
72 | pytest.param(
73 | "not-found-id",
74 | TodoUpdate(title="test-update-title", description="test-update-description").dict(by_alias=True),
75 | status.HTTP_404_NOT_FOUND,
76 | None,
77 | None,
78 | id="error_id_not_found",
79 | ),
80 | ],
81 | )
82 | async def test_update(
83 | self,
84 | authed_client: AsyncClient,
85 | id: str,
86 | data_in: dict,
87 | expected_status: int,
88 | expected_data: dict | None,
89 | expected_error: dict | None,
90 | data_set: None,
91 | ) -> None:
92 | await assert_update(
93 | self.ENDPOINT_URI,
94 | client=authed_client,
95 | id=id,
96 | data_in=data_in,
97 | expected_status=expected_status,
98 | expected_data=expected_data,
99 | )
100 |
101 | """get_by_id
102 | """
103 |
104 | @pytest.mark.parametrize(
105 | [
106 | "id",
107 | "expected_status",
108 | "expected_data",
109 | "expected_error",
110 | ],
111 | [
112 | pytest.param(
113 | "1",
114 | status.HTTP_200_OK,
115 | {"title": "test-title-1", "description": "test-description-1"},
116 | None,
117 | id="success",
118 | ),
119 | pytest.param(
120 | "not-found-id",
121 | status.HTTP_404_NOT_FOUND,
122 | None,
123 | None,
124 | id="error_id_not_found",
125 | ),
126 | ],
127 | )
128 | async def test_get_by_id(
129 | self,
130 | authed_client: AsyncClient,
131 | id: str,
132 | expected_status: int,
133 | expected_data: dict | None,
134 | expected_error: dict | None,
135 | data_set: None,
136 | ) -> None:
137 | await assert_get_by_id(
138 | self.ENDPOINT_URI,
139 | client=authed_client,
140 | id=id,
141 | expected_status=expected_status,
142 | expected_data=expected_data,
143 | )
144 |
145 | """get_paged_list
146 | """
147 |
148 | @pytest.mark.parametrize(
149 | [
150 | "params",
151 | "expected_status",
152 | "expected_first_data",
153 | "expected_paging_meta",
154 | "expected_error",
155 | ],
156 | [
157 | pytest.param(
158 | {"q": "", "perPage": 10},
159 | status.HTTP_200_OK,
160 | {"title": "test-title-24", "description": "test-description-24"},
161 | PagingMeta(current_page=1, total_page_count=3, total_data_count=24, per_page=10).dict(by_alias=True),
162 | None,
163 | id="success",
164 | )
165 | ],
166 | )
167 | async def test_get_paged_list(
168 | self,
169 | authed_client: AsyncClient,
170 | params: dict[str, Any],
171 | expected_status: int,
172 | expected_first_data: dict | None,
173 | expected_paging_meta: dict,
174 | expected_error: dict | None,
175 | data_set: None,
176 | ) -> None:
177 | await assert_get_paged_list(
178 | self.ENDPOINT_URI,
179 | client=authed_client,
180 | params=params,
181 | expected_status=expected_status,
182 | expected_first_data=expected_first_data,
183 | expected_paging_meta=expected_paging_meta,
184 | )
185 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from collections.abc import AsyncGenerator
3 | from typing import Any
4 |
5 | import pytest
6 | import pytest_asyncio
7 | from fastapi import status
8 | from httpx import AsyncClient
9 | from pydantic_settings import SettingsConfigDict
10 | from pytest_mysql import factories
11 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
12 | from sqlalchemy.orm import sessionmaker
13 | from sqlalchemy.pool import NullPool
14 | from sqlalchemy.sql import select
15 |
16 | import alembic.command
17 | import alembic.config
18 | from app import schemas
19 | from app.core.config import Settings
20 | from app.core.database import get_async_db
21 | from app.main import app
22 | from app.models.base import Base
23 | from app.models.users import User
24 |
25 | logging.basicConfig(
26 | level=logging.DEBUG,
27 | format="%(asctime)s %(name)s %(levelname)s %(message)s %(filename)s %(module)s %(funcName)s %(lineno)d",
28 | )
29 | logger = logging.getLogger(__name__)
30 |
31 |
32 | pytest.USER_ID: str | None = None
33 | pytest.USER_DICT: dict[str, Any] | None = None
34 | pytest.ACCESS_TOKEN: str | None = None
35 |
36 | logger.info("root-conftest")
37 |
38 |
39 | class TestSettings(Settings):
40 | """テストのみで使用する設定を記述"""
41 |
42 | TEST_USER_EMAIL: str = "test-user1@example.com"
43 | TEST_USER_PASSWORD: str = "test-user"
44 |
45 | model_config = SettingsConfigDict(env_file=".env.test")
46 |
47 |
48 | settings = TestSettings()
49 |
50 | logger.debug("start:mysql_proc")
51 | db_proc = factories.mysql_noproc(
52 | host=settings.DB_HOST,
53 | port=settings.DB_PORT,
54 | user=settings.DB_USER_NAME,
55 | )
56 | mysql = factories.mysql("db_proc")
57 | logger.debug("end:mysql_proc")
58 |
59 |
60 | TEST_USER_CREATE_SCHEMA = schemas.UserCreate(
61 | email=settings.TEST_USER_EMAIL,
62 | password=settings.TEST_USER_PASSWORD,
63 | full_name="test_user",
64 | )
65 |
66 |
67 | def migrate(
68 | versions_path: str,
69 | migrations_path: str,
70 | uri: str,
71 | alembic_ini_path: str,
72 | connection: Any = None,
73 | revision: str = "head",
74 | ) -> None:
75 | config = alembic.config.Config(alembic_ini_path)
76 | config.set_main_option("version_locations", versions_path)
77 | config.set_main_option("script_location", migrations_path)
78 | config.set_main_option("sqlalchemy.url", uri)
79 | # config.set_main_option("is_test", "1")
80 | if connection is not None:
81 | config.attributes["connection"] = connection
82 | alembic.command.upgrade(config, revision)
83 |
84 |
85 | @pytest_asyncio.fixture
86 | async def engine(
87 | mysql: Any, # noqa: ARG001
88 | ) -> AsyncEngine:
89 | """fixture: db-engineの作成およびmigrate"""
90 | logger.debug("fixture:engine")
91 | # uri = (
92 | # f"mysql+aiomysql://{settings.TEST_DB_USER}:"
93 | # f"@{settings.TEST_DB_HOST}:{settings.TEST_DB_PORT}/{settings.TEST_DB_NAME}?charset=utf8mb4"
94 | # )
95 | uri = settings.get_database_url(is_async=True)
96 | # sync_uri = (
97 | # f"mysql://{settings.TEST_DB_USER}:"
98 | # f"@{settings.TEST_DB_HOST}:{settings.TEST_DB_PORT}/{settings.TEST_DB_NAME}?charset=utf8mb4"
99 | # )
100 | # settings.DATABASE_URI = uri
101 | engine = create_async_engine(uri, echo=False, poolclass=NullPool)
102 |
103 | # NOTE: テストケース毎にmigrateすると時間がかるので使用停止
104 | # migrate(alembic)はasyncに未対応なため、sync-engineを使用する
105 | # with sync_engine.begin() as conn:
106 | # migrate(
107 | # migrations_path=settings.MIGRATIONS_DIR_PATH,
108 | # versions_path=os.path.join(settings.MIGRATIONS_DIR_PATH, "versions"),
109 | # alembic_ini_path=os.path.join(settings.ROOT_DIR_PATH, "alembic.ini"),
110 | # connection=conn,
111 | # uri=sync_uri,
112 | # )
113 | # logger.debug("migration end")
114 |
115 | # create_allで一括処理する
116 | async with engine.begin() as conn:
117 | await conn.run_sync(Base.metadata.create_all)
118 | return engine
119 |
120 |
121 | @pytest_asyncio.fixture
122 | async def db(
123 | engine: AsyncEngine,
124 | ) -> AsyncGenerator[AsyncSession, None]:
125 | """fixture: db-sessionの作成"""
126 | logger.debug("fixture:db")
127 | test_session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
128 |
129 | async with test_session_factory() as session:
130 | yield session
131 | await session.commit()
132 |
133 |
134 | @pytest_asyncio.fixture
135 | async def client(engine: AsyncEngine) -> AsyncClient:
136 | """fixture: HTTP-Clientの作成"""
137 | logger.debug("fixture:client")
138 | test_session_factory = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession)
139 |
140 | async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
141 | async with test_session_factory() as session:
142 | yield session
143 | await session.commit()
144 |
145 | # get_dbをTest用のDBを使用するようにoverrideする
146 | app.dependency_overrides[get_async_db] = override_get_db
147 | app.debug = False
148 | return AsyncClient(app=app, base_url="http://test")
149 |
150 |
151 | async def _insert_user(db: AsyncSession, user_dict: dict[str, Any]) -> None:
152 | del user_dict["_sa_instance_state"]
153 | db.add(User(**user_dict))
154 | await db.commit()
155 |
156 |
157 | @pytest_asyncio.fixture
158 | async def authed_client(client: AsyncClient, db: AsyncSession) -> AsyncClient:
159 | """fixture: clietnに認証情報をセット"""
160 | logger.debug("fixture:authed_client")
161 |
162 | if pytest.USER_DICT:
163 | # すでに1度ユーザー登録している場合は、過去に登録したレコードを再登録する
164 | _insert_user(db, user_dict=pytest.USER_DICT)
165 | logger.debug("already user created. restore user.")
166 | client.headers = {"authorization": f"Bearer {pytest.ACCESS_TOKEN}"}
167 | return client
168 |
169 | # ユーザー登録
170 | res = await client.post(
171 | "/users",
172 | json=TEST_USER_CREATE_SCHEMA.dict(),
173 | )
174 | assert res.status_code == status.HTTP_200_OK
175 |
176 | # ログインしてアクセストークンを取得
177 | res = await client.post(
178 | "/auth/login",
179 | data={
180 | "username": settings.TEST_USER_EMAIL,
181 | "password": settings.TEST_USER_PASSWORD,
182 | },
183 | )
184 | assert res.status_code == status.HTTP_200_OK
185 | access_token = res.json().get("access_token")
186 | client.headers = {"authorization": f"Bearer {access_token}"}
187 | pytest.ACCESS_TOKEN = access_token
188 |
189 | # 登録したユーザーIDを取得
190 | res = await client.get("users/me")
191 | assert res.json().get("id")
192 | pytest.USER_ID = res.json().get("id")
193 |
194 | # ユーザーレコードを丸ごと取得して、次回以降のテストを高速化する
195 | stmt = select(User).where(User.id == res.json().get("id"))
196 | user = (await db.execute(stmt)).scalars().first()
197 | pytest.USER_DICT = user.__dict__.copy()
198 |
199 | return client
200 |
201 |
202 | # @pytest.fixture
203 | # def USER_ID(authed_client):
204 | # return pytest.USER_ID
205 |
--------------------------------------------------------------------------------
/app/crud/base.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import math
3 | from enum import Enum
4 | from typing import Any, Generic, TypeVar
5 |
6 | from fastapi.encoders import jsonable_encoder
7 | from pydantic import BaseModel
8 | from sqlalchemy.inspection import inspect
9 | from sqlalchemy.orm import Session
10 | from sqlalchemy.orm.properties import ColumnProperty
11 |
12 | from app import schemas
13 | from app.exceptions.core import APIException
14 | from app.exceptions.error_messages import ErrorMessage
15 | from app.models.base import Base
16 | from app.schemas.core import PagingQueryIn
17 |
18 | ModelType = TypeVar("ModelType", bound=Base)
19 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
20 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
21 | ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
22 | ListResponseSchemaType = TypeVar("ListResponseSchemaType", bound=BaseModel)
23 |
24 |
25 | class CRUDBase(
26 | Generic[
27 | ModelType,
28 | ResponseSchemaType,
29 | CreateSchemaType,
30 | UpdateSchemaType,
31 | ListResponseSchemaType,
32 | ],
33 | ):
34 | def __init__(
35 | self,
36 | model: type[ModelType],
37 | response_schema_class: type[ResponseSchemaType],
38 | list_response_class: type[ListResponseSchemaType],
39 | ) -> None:
40 | self.model = model
41 | self.response_schema_class = response_schema_class
42 | self.list_response_class = list_response_class
43 |
44 | def _get_select_columns(self) -> list[ColumnProperty]:
45 | """ResponseSchemaに含まれるfieldのみをsqlalchemyのselect用のobjectとして返す."""
46 | schema_columns = list(self.response_schema_class.model_fields.keys())
47 | mapper = inspect(self.model)
48 | select_columns = [
49 | attr for attr in mapper.attrs if isinstance(attr, ColumnProperty) and attr.key in schema_columns
50 | ]
51 |
52 | return select_columns
53 |
54 | def _filter_model_exists_fields(self, data_dict: dict[str, Any]) -> dict[str, Any]:
55 | """data_dictを与え、modelに存在するfieldだけをfilterして返す."""
56 | data_fields = list(data_dict.keys())
57 | mapper = inspect(self.model)
58 | exists_data_dict = {}
59 | for attr in mapper.attrs:
60 | if isinstance(attr, ColumnProperty) and attr.key in data_fields:
61 | exists_data_dict[attr.key] = data_dict[attr.key]
62 |
63 | return exists_data_dict
64 |
65 | def _get_order_by_clause(
66 | self,
67 | sort_field: Any | Enum,
68 | ) -> ColumnProperty | None:
69 | sort_field_value = sort_field.value if isinstance(sort_field, Enum) else sort_field
70 | mapper = inspect(self.model)
71 | order_by_clause = [
72 | attr for attr in mapper.attrs if isinstance(attr, ColumnProperty) and attr.key == sort_field_value
73 | ]
74 |
75 | return order_by_clause[0] if order_by_clause else None
76 |
77 | def get_db_obj_by_id(
78 | self,
79 | db: Session,
80 | id: Any,
81 | include_deleted: bool = False,
82 | ) -> ModelType | None:
83 | db_obj = (
84 | db.query(self.model).filter(self.model.id == id).execution_options(include_deleted=include_deleted).first()
85 | )
86 | return db_obj
87 |
88 | def get_db_obj_list(
89 | self,
90 | db: Session,
91 | where_clause: list[Any] | None = None,
92 | sort_query_in: schemas.SortQueryIn | None = None,
93 | include_deleted: bool = False,
94 | ) -> list[ModelType]:
95 | query = db.query(self.model)
96 | if where_clause is not None:
97 | query = query.filter(*where_clause)
98 | if sort_query_in:
99 | order_by_clause = self._get_order_by_clause(sort_query_in.sort_field)
100 | query = sort_query_in.apply_to_query(query, order_by_clause=order_by_clause)
101 | db_obj_list = query.execution_options(include_deleted=include_deleted).all()
102 |
103 | return db_obj_list
104 |
105 | def get_paged_list(
106 | self,
107 | db: Session,
108 | paging_query_in: PagingQueryIn,
109 | where_clause: list[Any] | None = None,
110 | sort_query_in: schemas.SortQueryIn | None = None,
111 | include_deleted: bool = False,
112 | ) -> ListResponseSchemaType:
113 | """Notes
114 | include_deleted=Trueの場合は、削除フラグ=Trueのデータも返す.
115 | """
116 | where_clause = where_clause if where_clause is not None else []
117 | total_count = db.query(self.model).filter(*where_clause).count()
118 |
119 | select_columns = self._get_select_columns()
120 | query = db.query(*select_columns).filter(*where_clause)
121 | if sort_query_in:
122 | order_by_clause = self._get_order_by_clause(sort_query_in.sort_field)
123 | query = sort_query_in.apply_to_query(query, order_by_clause=order_by_clause)
124 | query = paging_query_in.apply_to_query(query)
125 | query = query.execution_options(include_deleted=include_deleted)
126 | data = query.all()
127 | meta = schemas.PagingMeta(
128 | total_data_count=total_count,
129 | current_page=paging_query_in.page,
130 | total_page_count=int(math.ceil(total_count / paging_query_in.per_page)),
131 | per_page=paging_query_in.per_page,
132 | )
133 | list_response = self.list_response_class(data=data, meta=meta)
134 |
135 | return list_response
136 |
137 | def create(self, db: Session, create_schema: CreateSchemaType) -> ModelType:
138 | # by_alias=Falseにしないとalias側(CamenCase)が採用されてしまう
139 | create_dict = jsonable_encoder(create_schema, by_alias=False)
140 | exists_create_dict = self._filter_model_exists_fields(create_dict)
141 | db_obj = self.model(**exists_create_dict)
142 | print(db_obj.__dict__)
143 | db.add(db_obj)
144 | db.flush()
145 | db.refresh(db_obj)
146 |
147 | return db_obj
148 |
149 | def update(
150 | self,
151 | db: Session,
152 | *,
153 | db_obj: ModelType,
154 | update_schema: UpdateSchemaType,
155 | ) -> ModelType:
156 | # obj_inでセットされたスキーマをmodelの各カラムにUpdate
157 | db_obj_dict = jsonable_encoder(db_obj)
158 | update_dict = update_schema.dict(
159 | exclude_unset=True,
160 | ) # exclude_unset=Trueとすることで、未指定のカラムはUpdateしない
161 | for field in db_obj_dict:
162 | if field in update_dict:
163 | setattr(db_obj, field, update_dict[field])
164 |
165 | db.add(db_obj)
166 | db.flush()
167 | db.refresh(db_obj)
168 | return db_obj
169 |
170 | def delete(self, db: Session, db_obj: ModelType) -> ModelType:
171 | if db_obj.deleted_at:
172 | raise APIException(ErrorMessage.ALREADY_DELETED)
173 | db_obj.deleted_at = datetime.datetime.now(tz=datetime.timezone.utc)
174 | print(db_obj)
175 | db.add(db_obj)
176 | db.flush()
177 | db.refresh(db_obj)
178 | return db_obj
179 |
--------------------------------------------------------------------------------
/app/crud_v2/base.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import math
3 | from enum import Enum
4 | from typing import Any, Generic, TypeVar
5 |
6 | from fastapi.encoders import jsonable_encoder
7 | from pydantic import BaseModel
8 | from sqlalchemy.ext.asyncio import AsyncSession
9 | from sqlalchemy.inspection import inspect
10 | from sqlalchemy.orm.properties import ColumnProperty
11 | from sqlalchemy.sql import func, select
12 |
13 | from app import schemas
14 | from app.exceptions.core import APIException
15 | from app.exceptions.error_messages import ErrorMessage
16 | from app.models.base import Base
17 | from app.schemas.core import PagingQueryIn
18 |
19 | ModelType = TypeVar("ModelType", bound=Base)
20 | CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
21 | UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
22 | ResponseSchemaType = TypeVar("ResponseSchemaType", bound=BaseModel)
23 | ListResponseSchemaType = TypeVar("ListResponseSchemaType", bound=BaseModel)
24 |
25 |
26 | class CRUDV2Base(
27 | Generic[
28 | ModelType,
29 | ResponseSchemaType,
30 | CreateSchemaType,
31 | UpdateSchemaType,
32 | ListResponseSchemaType,
33 | ],
34 | ):
35 | def __init__(
36 | self,
37 | model: type[ModelType],
38 | response_schema_class: type[ResponseSchemaType],
39 | list_response_class: type[ListResponseSchemaType],
40 | ) -> None:
41 | self.model = model
42 | self.response_schema_class = response_schema_class
43 | self.list_response_class = list_response_class
44 |
45 | def _get_select_columns(self) -> list[ColumnProperty]:
46 | """ResponseSchemaに含まれるfieldのみをsqlalchemyのselect用のobjectとして返す."""
47 | schema_columns = list(self.response_schema_class.model_fields.keys())
48 | mapper = inspect(self.model)
49 | select_columns = [
50 | getattr(self.model, attr.key)
51 | for attr in mapper.attrs
52 | if isinstance(attr, ColumnProperty) and attr.key in schema_columns
53 | ]
54 |
55 | return select_columns
56 |
57 | def _filter_model_exists_fields(self, data_dict: dict[str, Any]) -> dict[str, Any]:
58 | """data_dictを与え、modelに存在するfieldだけをfilterして返す."""
59 | data_fields = list(data_dict.keys())
60 | mapper = inspect(self.model)
61 | exists_data_dict = {}
62 | for attr in mapper.attrs:
63 | if isinstance(attr, ColumnProperty) and attr.key in data_fields:
64 | exists_data_dict[attr.key] = data_dict[attr.key]
65 |
66 | return exists_data_dict
67 |
68 | def _get_order_by_clause(
69 | self,
70 | sort_field: Any | Enum,
71 | ) -> ColumnProperty | None:
72 | sort_field_value = sort_field.value if isinstance(sort_field, Enum) else sort_field
73 | mapper = inspect(self.model)
74 | order_by_clause = [
75 | attr for attr in mapper.attrs if isinstance(attr, ColumnProperty) and attr.key == sort_field_value
76 | ]
77 |
78 | return order_by_clause[0] if order_by_clause else None
79 |
80 | async def get_db_obj_by_id(
81 | self,
82 | db: AsyncSession,
83 | id: Any,
84 | include_deleted: bool = False,
85 | ) -> ModelType | None:
86 | stmt = select(self.model).where(self.model.id == id).execution_options(include_deleted=include_deleted)
87 | return (await db.execute(stmt)).scalars().first()
88 |
89 | async def get_db_obj_list(
90 | self,
91 | db: AsyncSession,
92 | where_clause: list[Any] | None = None,
93 | sort_query_in: schemas.SortQueryIn | None = None,
94 | include_deleted: bool = False,
95 | ) -> list[ModelType]:
96 | where_clause = where_clause if where_clause is not None else []
97 | stmt = select(self.model).where(*where_clause)
98 | if sort_query_in:
99 | order_by_clause = self._get_order_by_clause(sort_query_in.sort_field)
100 | stmt = sort_query_in.apply_to_query(stmt, order_by_clause=order_by_clause)
101 |
102 | db_obj_list = (await db.execute(stmt.execution_options(include_deleted=include_deleted))).all()
103 | return db_obj_list
104 |
105 | async def get_paged_list(
106 | self,
107 | db: AsyncSession,
108 | paging_query_in: PagingQueryIn,
109 | where_clause: list[Any] | None = None,
110 | sort_query_in: schemas.SortQueryIn | None = None,
111 | include_deleted: bool = False,
112 | ) -> ListResponseSchemaType:
113 | """Notes
114 | include_deleted=Trueの場合は、削除フラグ=Trueのデータも返す.
115 | """
116 | where_clause = where_clause if where_clause is not None else []
117 | stmt = select(func.count(self.model.id)).where(*where_clause).execution_options(include_deleted=include_deleted)
118 | total_count = (await db.execute(stmt)).scalar()
119 |
120 | select_columns = self._get_select_columns()
121 | stmt = select(*select_columns).where(*where_clause)
122 | if sort_query_in:
123 | order_by_clause = self._get_order_by_clause(sort_query_in.sort_field)
124 | stmt = sort_query_in.apply_to_query(stmt, order_by_clause=order_by_clause)
125 | stmt = stmt.execution_options(include_deleted=include_deleted)
126 | stmt = paging_query_in.apply_to_query(stmt)
127 | db_obj_list = (await db.execute(stmt)).all()
128 |
129 | meta = schemas.PagingMeta(
130 | total_data_count=total_count,
131 | current_page=paging_query_in.page,
132 | total_page_count=int(math.ceil(total_count / paging_query_in.per_page)),
133 | per_page=paging_query_in.per_page,
134 | )
135 | data_response = [self.response_schema_class.model_validate(d) for d in db_obj_list]
136 | list_response = self.list_response_class(data=data_response, meta=meta)
137 | return list_response
138 |
139 | async def create(
140 | self,
141 | db: AsyncSession,
142 | create_schema: CreateSchemaType,
143 | ) -> ModelType:
144 | # by_alias=Falseにしないとalias側(CamenCase)が採用されてしまう
145 | create_dict = jsonable_encoder(create_schema, by_alias=False)
146 | exists_create_dict = self._filter_model_exists_fields(create_dict)
147 | db_obj = self.model(**exists_create_dict)
148 | db.add(db_obj)
149 | await db.flush()
150 | await db.refresh(db_obj)
151 |
152 | return db_obj
153 |
154 | async def update(
155 | self,
156 | db: AsyncSession,
157 | *,
158 | db_obj: ModelType,
159 | update_schema: UpdateSchemaType,
160 | ) -> ModelType:
161 | # obj_inでセットされたスキーマをmodelの各カラムにUpdate
162 | db_obj_dict = jsonable_encoder(db_obj)
163 | update_dict = update_schema.dict(
164 | exclude_unset=True,
165 | ) # exclude_unset=Trueとすることで、未指定のカラムはUpdateしない
166 | for field in db_obj_dict:
167 | if field in update_dict:
168 | setattr(db_obj, field, update_dict[field])
169 |
170 | db.add(db_obj)
171 | await db.flush()
172 | await db.refresh(db_obj)
173 | return db_obj
174 |
175 | async def delete(self, db: AsyncSession, db_obj: ModelType) -> ModelType:
176 | """論理削除(soft delete)."""
177 | if not hasattr(db_obj, "deleted_at"):
178 | raise APIException(ErrorMessage.SOFT_DELETE_NOT_SUPPORTED)
179 | if db_obj.deleted_at:
180 | raise APIException(ErrorMessage.ALREADY_DELETED)
181 |
182 | db_obj.deleted_at = datetime.datetime.now(tz=datetime.timezone.utc)
183 | await db.add(db_obj)
184 | await db.flush()
185 | await db.refresh(db_obj)
186 | return db_obj
187 |
188 | async def real_delete(self, db: AsyncSession, db_obj: ModelType) -> None:
189 | """実削除(real redele)."""
190 | await db.delete(db_obj)
191 | await db.flush()
192 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # FastAPI-実用的テンプレート(FastAPI Template)
2 |
3 | This is a practical template using FastAPI.
4 | It is dockerized including DB (MySQL) and uvicorn.
5 | Package management is implemented using rye.
6 | We apologize for the inconvenience.
7 | The following text is in Japanese only.
8 | Please use automatic translation, etc.
9 |
10 | FastAPI を使用した実用的なテンプレートです。
11 | DB(MySQL)と uvicorn を含めて docker 化しています。
12 | rye を使用して、パッケージ管理を実装しています。
13 |
14 | # 機能追加要望・改善要望・バグ報告(Feature reports, Improvement reports, Bug reports)
15 |
16 | Please send improvement requests and bug reports to Issue.
17 | Issue からお願いします。可能な限り対応いたします。
18 |
19 | # 必須環境(Required Configuration)
20 |
21 | - Python 3.9+
22 |
23 | # SQLAlchemy1.4(sync)版 (旧バージョン)
24 | SQLAlchemy1.4(sync)版は旧バージョンのため、今後Updateの予定はありませんが、使用したい場合はmaster-old-sqlalchemy14 ブランチを参照してください。
25 |
26 | # デモ環境(Heroku Demos)
27 |
28 | 本リポジトリに、heroku にデプロイするための設定ファイルも含まれています。
29 | デプロイ済の環境は以下から参照できます。
30 |
31 | ```
32 | https://fastapi-sample-tk.herokuapp.com/docs
33 | ```
34 |
35 | # プロジェクト構造(Project Structures)
36 |
37 | ```text
38 | .
39 | ├── Dockerfile // 通常使用するDockerfile
40 | ├── Dockerfile.lambda // Lambdaにデプロイする場合のDockerfile
41 | ├── LICENSE.md
42 | ├── Makefile // タスクランナーを定義したMakefile
43 | ├── Procfile
44 | ├── alembic // migrationに使用するalembicのディレクトリ
45 | │ ├── README
46 | │ ├── env.py
47 | │ ├── script.py.mako
48 | │ └── versions
49 | │ └── 20230131-0237_.py
50 | ├── alembic.ini
51 | ├── app // mainのソースコードディレクト遺r
52 | │ ├── __init__.py
53 | │ ├── api // WebAPI Endpoint
54 | │ │ └── endpoints
55 | │ │ ├── __init__.py
56 | │ │ ├── auth.py
57 | │ │ ├── develop.py
58 | │ │ ├── tasks.py
59 | │ │ ├── todos.py
60 | │ │ └── users.py
61 | │ ├── commands
62 | │ │ ├── __init__.py
63 | │ │ ├── __set_base_path__.py
64 | │ │ └── user_creation.py
65 | │ ├── core // 共通で共通するCore機能
66 | │ │ ├── __init__.py
67 | │ │ ├── auth.py
68 | │ │ ├── config.py
69 | │ │ ├── database.py
70 | │ │ ├── logger
71 | │ │ │ ├── __init__.py
72 | │ │ │ └── logger.py
73 | │ │ └── utils.py
74 | │ ├── crud // crudディレクトリ(Sqlalchemy v1.4のため、メンテ停止)
75 | │ │ ├── __init__.py
76 | │ │ ├── base.py
77 | │ │ ├── tag.py
78 | │ │ ├── todo.py
79 | │ │ └── user.py
80 | │ ├── crud_v2 // crudディレクトリ (sqlalchemy v2対応)
81 | │ │ ├── __init__.py
82 | │ │ ├── base.py
83 | │ │ ├── tag.py
84 | │ │ ├── todo.py
85 | │ │ └── user.py
86 | │ ├── exceptions // expectionsの定義
87 | │ │ ├── __init__.py
88 | │ │ ├── core.py
89 | │ │ ├── error_messages.py
90 | │ │ └── exception_handlers.py
91 | │ ├── logger_config.yaml
92 | │ ├── main.py // fastapiのmainファイル。uvicornで指定する
93 | │ ├── models // DBテーブルのmodel
94 | │ │ ├── __init__.py
95 | │ │ ├── base.py
96 | │ │ ├── tags.py
97 | │ │ ├── todos.py
98 | │ │ ├── todos_tags.py
99 | │ │ └── users.py
100 | │ └── schemas // 外部入出力用のschema
101 | │ ├── __init__.py
102 | │ ├── core.py
103 | │ ├── language_analyzer.py
104 | │ ├── request_info.py
105 | │ ├── tag.py
106 | │ ├── todo.py
107 | │ ├── token.py
108 | │ └── user.py
109 | ├── docker-compose.ecs.yml
110 | ├── docker-compose.es.yml
111 | ├── docker-compose.yml
112 | ├── docs
113 | │ ├── docs
114 | │ │ ├── index.md
115 | │ │ └── install.md
116 | │ └── mkdocs.yml
117 | ├── elasticsearch
118 | │ ├── docker-compose.yml
119 | │ ├── elasticsearch
120 | │ │ └── Dockerfile.es
121 | │ ├── logstash
122 | │ │ ├── Dockerfile
123 | │ │ └── pipeline
124 | │ │ └── main.conf
125 | │ └── readme.md
126 | ├── flake8.ini
127 | ├── frontend_sample // Frontend(react)からBackendを呼び出すサンプル
128 | │ ├── README.md
129 | │ ├── next.config.js
130 | │ ├── package-lock.json
131 | │ ├── package.json
132 | │ ├── public
133 | │ │ ├── favicon.ico
134 | │ │ └── vercel.svg
135 | │ ├── src
136 | │ │ ├── api_clients
137 | │ │ │ ├── api.ts
138 | │ │ │ ├── base.ts
139 | │ │ │ ├── client.ts
140 | │ │ │ ├── common.ts
141 | │ │ │ ├── configuration.ts
142 | │ │ │ ├── git_push.sh
143 | │ │ │ └── index.ts
144 | │ │ ├── components
145 | │ │ │ └── templates
146 | │ │ │ └── todos
147 | │ │ │ ├── TodoCreateTemplate
148 | │ │ │ │ └── TodoCreateTemplate.tsx
149 | │ │ │ ├── TodoUpdateTemplate
150 | │ │ │ │ └── TodoUpdateTemplate.tsx
151 | │ │ │ └── TodosListTemplate
152 | │ │ │ └── TodosListTemplate.tsx
153 | │ │ ├── config.ts
154 | │ │ ├── lib
155 | │ │ │ └── hooks
156 | │ │ │ └── api
157 | │ │ │ ├── index.ts
158 | │ │ │ └── todos.ts
159 | │ │ ├── pages
160 | │ │ │ ├── _app.tsx
161 | │ │ │ ├── index.tsx
162 | │ │ │ └── todos
163 | │ │ │ ├── create.tsx
164 | │ │ │ ├── edit.tsx
165 | │ │ │ └── list.tsx
166 | │ │ ├── styles
167 | │ │ │ ├── Home.module.css
168 | │ │ │ └── globals.css
169 | │ │ └── types
170 | │ │ └── api
171 | │ │ └── index.ts
172 | │ └── tsconfig.json
173 | ├── mypy.ini
174 | ├── pyproject.toml
175 | ├── Makefile
176 | ├── readme.md
177 | ├── requirements-dev.lock
178 | ├── requirements.lock
179 | ├── runtime.txt
180 | ├── seeder // seedの定義、インポーター
181 | │ ├── run.py
182 | │ └── seeds_json
183 | │ ├── todos.json
184 | │ └── users.json
185 | └── tests // test
186 | ├── __init__.py
187 | ├── base.py
188 | ├── conftest.py
189 | ├── testing_utils.py
190 | └── todos
191 | ├── __init__.py
192 | ├── conftest.py
193 | └── test_todos.py
194 | ```
195 |
196 | # 機能(Features)
197 |
198 | ## パッケージ管理、タスクランナー管理(Package management, task runner management)
199 |
200 | rye を使用してパッケージ管理を行い、makefileでタスクランナーを管理しています。
201 | 詳しい定義内容は、Makefile を参照してください。
202 |
203 | ## DB レコードの作成・取得・更新・削除(CRUD)
204 |
205 | crud/base.py に CRUD の共通 Class を実装しています。
206 | 個別の CRUD 実装時は、この共通 Class を継承して個別処理を実装してください。
207 |
208 | ## 論理削除の CRUD 管理(Software delete)
209 |
210 | DB レコード削除時に実際には削除せず deleted_at に削除日付をセットすることで
211 | 論理削除を実装しています。
212 |
213 | 以下のように SQLAlchemy の event 機能を使用して、ORM 実行後に自動的に論理削除レコードを除外するための filter を追加しています。
214 | これにより、個別の CRUD で論理削除を実装する必要がなくなります。
215 | include_deleted=True とすると、論理削除済レコードも取得できます。
216 |
217 | ```python
218 | @event.listens_for(Session, "do_orm_execute")
219 | def _add_filtering_deleted_at(execute_state):
220 | """
221 | 論理削除用のfilterを自動的に適用する
222 | 以下のようにすると、論理削除済のデータも含めて取得可能
223 | query(...).filter(...).execution_options(include_deleted=True)
224 | """
225 | logger.info(execute_state)
226 | if (
227 | not execute_state.is_column_load
228 | and not execute_state.is_relationship_load
229 | and not execute_state.execution_options.get("include_deleted", False)
230 | ):
231 | execute_state.statement = execute_state.statement.options(
232 | orm.with_loader_criteria(
233 | ModelBase,
234 | lambda cls: cls.deleted_at.is_(None),
235 | include_aliases=True,
236 | )
237 | )
238 |
239 | ```
240 |
241 | ## 権限(Scopes)
242 |
243 | 特定の User のみが実行できる API を作成する場合は、
244 | table の user.scopes の値と router に指定した scope を一致させてください。
245 |
246 | ```python
247 | @router.get(
248 | "/{id}",
249 | response_model=schemas.UserResponse,
250 | dependencies=[Security(get_current_user, scopes=["admin"])],
251 | )
252 | ```
253 |
254 | ## キャメルケースとスネークケースの相互変換(Mutual conversion between CamelCase and SnakeCase)
255 |
256 | Python ではスネークケースが標準ですが、Javascript ではキャメルケースが標準なため
257 | 単純に pydantic で schema を作成しただけでは、json レスポンスにスネークケースを使用せざるをえない問題があります。
258 |
259 | そこで、以下のような BaseSchema を作成して、キャメルケース、スネークケースの相互変換を行なっています。
260 | pydantic v2では、```from pydantic.alias_generators import to_camel```をインポートして、ConfigDictのalias_generatorにセットすることで、簡単にキャメルケースとスネークケースの相互変換が実現できます。
261 |
262 | ```python
263 | from pydantic import BaseModel, ConfigDict
264 | from pydantic.alias_generators import to_camel
265 |
266 | class BaseSchema(BaseModel):
267 | """全体共通の情報をセットするBaseSchema"""
268 |
269 | # class Configで指定した場合に引数チェックがされないため、ConfigDictを推奨
270 | model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, strict=True)
271 |
272 |
273 | ```
274 |
275 | ## OpenAPI Generator を使用してバックエンドの型定義をフロントエンドでも使用する
276 |
277 | FastAPI を使用するとエンドポイントを作成した段階で openapi.json が自動生成されます。
278 |
279 | OpenAPI-Generator は、この openapi.json を読み込んで、フロントエンド用の型定義や API 呼び出し用コードを自動生成する仕組みです。
280 |
281 | docker-compose 内で定義しており、docker compose up で実行される他、`make openapi-generator`を実行すると openapi-generator だけを実行できます。
282 |
283 | 生成されたコードは、`/fontend_sample/src/api_clients`に格納されます。(-o オプションで変更可能)
284 |
285 | ```yml
286 | # openapiのclient用のコードを自動生成するコンテナ
287 | openapi-generator:
288 | image: openapitools/openapi-generator-cli
289 | depends_on:
290 | web:
291 | condition: service_healthy
292 | volumes:
293 | - ./frontend_sample:/fontend_sample
294 | command: generate -i http://web/openapi.json -g typescript-axios -o /fontend_sample/src/api_clients --skip-validate-spec
295 | networks:
296 | - fastapi_network
297 | ```
298 |
299 | ## バッチ処理(Batch)
300 |
301 | サブディレクトリ配下の py ファイルから、別ディレクトリのファイルを import する場合は
302 | その前に以下のコードを記述する必要があります。
303 |
304 | ```python
305 | sys.path.append(str(Path(__file__).absolute().parent.parent))
306 | ```
307 |
308 | batch/**set_base_path**.py に記述し、各ファイルの先頭で import することで
309 | より簡単に import できるようにしています。
310 |
311 | ## Settings
312 |
313 | core/config.py にて、BaseSettings を継承して共通設定 Class を定義しています。
314 | .env ファイルから自動的に設定を読み込むことができる他、個別に設定を定義することもできます。
315 |
316 | ## CORS-ORIGIN
317 |
318 | CORS ORIGIN は大きく2パターンの設定方法があります。
319 | allow_origins に list を指定する方法では、settings.CORS_ORIGINS に url を指定することで
320 | 任意の ORIGIN が設定可能です。
321 | また、https://\*\*\*\*.example.com のようにサブドメインを一括で許可したい場合は
322 | allow_origin_regex に以下のように正規表現で URL パターンを指定してください。
323 |
324 | ```python
325 | app.add_middleware(
326 | CORSMiddleware,
327 | allow_origins=[str(origin) for origin in settings.CORS_ORIGINS],
328 | allow_origin_regex=r"^https?:\/\/([\w\-\_]{1,}\.|)example\.com",
329 | allow_methods=["*"],
330 | allow_headers=["*"],
331 | )
332 | ```
333 |
334 | ## ErrorException
335 |
336 | exceptions/error_messages.py にエラーメッセージを定義しています。
337 | APIException と併せて以下のように、呼び出すことで、任意の HTTP コードのエラーレスポンスを作成できます。
338 |
339 | ```python
340 | raise APIException(ErrorMessage.INTERNAL_SERVER_ERROR)
341 | ```
342 |
343 | レスポンス例
344 |
345 | ```
346 | http status code=400
347 | {
348 | "detail": {
349 | "error_code": "INTERNAL_SERVER_ERROR",
350 | "error_msg": "システムエラーが発生しました、管理者に問い合わせてください"
351 | }
352 | }
353 | ```
354 |
355 | ## AppManager
356 |
357 | FastAPIのmount機能を使うと、多くのAPIを作成する場合などopenapiを複数画面に分割することができますが、openapi間のリンクが不便になる問題があります。
358 | そこで、複数のFastAPIのappを統合管理できるFastAPIAppManagerクラスを構築しています。
359 |
360 | FastAPIAppManagerを使用して、複数のappをadd_appで追加していくことで、複数のappに対する共通処理を実行することができます。
361 |
362 | 一例として以下の実装では、setup_apps_docs_link()でopenapiの上部に表示するapp間のlinkを生成しています。
363 |
364 | ```python
365 | # main.py
366 | app = FastAPI(
367 | title=settings.TITLE,
368 | version=settings.VERSION,
369 | debug=settings.DEBUG or False,
370 | )
371 | app_manager = FastAPIAppManager(root_app=app)
372 | # appを分割する場合は、add_appで別のappを追加する
373 | app_manager.add_app(path="admin", app=admin_app.app)
374 | app_manager.add_app(path="other", app=other_app.app)
375 | app_manager.setup_apps_docs_link()
376 | ```
377 |
378 | ```python
379 | # app_manager.py
380 | class FastAPIAppManager():
381 |
382 | def __init__(self, root_app: FastAPI):
383 | self.app_path_list: list[str] = [""]
384 | self.root_app: FastAPI = root_app
385 | self.apps: list[FastAPI] = [root_app]
386 |
387 | def add_app(self, app: FastAPI, path: str) -> None:
388 | self.apps.append(app)
389 | if not path.startswith("/"):
390 | path = f"/{path}"
391 | else:
392 | path =path
393 | self.app_path_list.append(path)
394 | app.title = f"{self.root_app.title}({path})"
395 | app.version = self.root_app.version
396 | app.debug = self.root_app.debug
397 | self.root_app.mount(path=path, app=app)
398 |
399 | def setup_apps_docs_link(self) -> None:
400 | """ 他のAppへのリンクがopenapiに表示されるようにセットする """
401 | for app, path in zip(self.apps, self.app_path_list):
402 | app.description = self._make_app_docs_link_html(path)
403 |
404 | def _make_app_docs_link_html(self, current_path: str) -> str:
405 | # openapiの上部に表示する各Appへのリンクを生成する
406 | descriptions = [
407 | f"{path}/docs" if path != current_path else f"{path}/docs"
408 | for path in self.app_path_list
409 | ]
410 | descriptions.insert(0, "Apps link")
411 | return "
".join(descriptions)
412 | ```
413 |
414 |
415 | ## logging
416 |
417 | logger_config.yaml で logging 設定を管理しています。可読性が高くなるように yaml で記述しています。
418 | uviron の起動時に`--log-config ./app/logger_config.yaml` のように option 指定して logger 設定を行います。
419 |
420 | ```yaml
421 | version: 1
422 | disable_existing_loggers: false # 既存のlogger設定を無効化しない
423 |
424 | formatters: # formatterの指定、ここではjsonFormatterを使用して、json化したlogを出力するようにしている
425 | json:
426 | format: "%(asctime)s %(name)s %(levelname)s %(message)s %(filename)s %(module)s %(funcName)s %(lineno)d"
427 | class: pythonjsonlogger.jsonlogger.JsonFormatter
428 |
429 | handlers: # handerで指定した複数種類のログを出力可能
430 | console:
431 | class: logging.StreamHandler
432 | level: DEBUG
433 | formatter: json
434 | stream: ext://sys.stdout
435 |
436 | loggers: # loggerの名称毎に異なるhandlerやloglevelを指定できる
437 | backend:
438 | level: INFO
439 | handlers: [console]
440 | propagate: false
441 |
442 | gunicorn.error:
443 | level: DEBUG
444 | handlers: [console]
445 | propagate: false
446 |
447 | uvicorn.access:
448 | level: INFO
449 | handlers: [console]
450 | propagate: false
451 |
452 | sqlalchemy.engine:
453 | level: INFO
454 | handlers: [console]
455 | propagate: false
456 |
457 | alembic.runtime.migration:
458 | level: INFO
459 | handlers: [console]
460 | propagate: false
461 |
462 | root:
463 | level: INFO
464 | handlers: [console]
465 | ```
466 |
467 | ## テスト(Testing)
468 |
469 | tests/ 配下に、テスト関連の処理を、まとめています。
470 |
471 | 実行時は、`make test`で実行できます。個々のテストケースを実行する場合は`make docker-run`でコンテナに入った後に`pytest tests/any/test/path.py`のようにファイルやディレクトリを指定して実行できます。
472 |
473 | pytest.fixture を使用して、テスト関数毎に DB の初期化処理を実施しているため、毎回クリーンな DB を使用してステートレスなテストが可能です。
474 |
475 | DB サーバーは docker で起動する DB コンテナを共用しますが、同じデータベースを使用してしまうと、テスト時にデータがクリアされてしまうので、別名のデータベースを作成しています。
476 |
477 | pytest では conftest.py に記述した内容は自動的に読み込まれるため、conftest.py にテストの前処理を記述しています。
478 |
479 | tests/conftest.py には、テスト全体で使用する設定の定義や DB や HTTP クライアントの定義を行っていおます。
480 |
481 | conftest.py は実行するテストファイルのある階層とそれより上の階層にあるものしか実行されないため、以下の例で test_todos.py を実行した場合は、`tests/todos/conftest.py` と `tests/conftest.py` のみが実行されます。
482 |
483 | ```
484 | test/
485 | conftest.py
486 | -- todos/
487 | -- conftest.py
488 | -- test_todos.py
489 | -- any-test/
490 | -- conftest.py
491 | -- test_any-test.py
492 | ```
493 |
494 | この仕様を活かして、todos/などの個々のテストケースのディレクトリ配下の conftest.py では、以下のように対象のテストケースのみで使用するデータのインポートを定義しています。
495 |
496 | ```python
497 | @pytest.fixture
498 | def data_set(db: Session):
499 | insert_todos(db)
500 |
501 |
502 | def insert_todos(db: Session):
503 | now = datetime.datetime.now()
504 | data = [
505 | models.Todo(
506 | id=str(i),
507 | title=f"test-title-{i}",
508 | description=f"test-description-{i}",
509 | created_at=now - datetime.timedelta(days=i),
510 | )
511 | for i in range(1, 25)
512 | ]
513 | db.add_all(data)
514 | db.commit()
515 | ```
516 |
517 | fixture で定義した data_set は、以下のようにテスト関数の引数に指定することで、テスト関数の前提処理として実行することができます。
518 |
519 | 引数に、`authed_client: Client`を指定することで、ログイン認証済の HTTP クライアントが取得できます。`client: Client`を指定した場合は、未認証の HTTP クライアントが取得できます。
520 |
521 | API エンドポイント経由のテストではなく、db セッションを直接指定するテストの場合は、`db: Session`を引数に指定することで、テスト用の db セッションを取得できます。
522 |
523 | ```python
524 | def test_get_by_id(
525 | self,
526 | authed_client: Client,
527 | id: str,
528 | expected_status: int,
529 | expected_data: Optional[dict],
530 | expected_error: Optional[dict],
531 | data_set: None, # <-- here
532 | ) -> None:
533 | self.assert_get_by_id(
534 | client=authed_client,
535 | id=id,
536 | expected_status=expected_status,
537 | expected_data=expected_data,
538 | )
539 | ```
540 |
541 | ```tests/base.py```にAPIテスト用のベースClassが定義されているので、これを継承することで、簡単にテスト関数を構築することができます。
542 |
543 | 以下の例では、TestBaseクラスを継承して、TestTodosクラスを作成しています。ENDPOINT_URIにテスト対象のAPIエンドポイントのURIを指定することで、CRUD全体で使用できます。
544 |
545 | pytestのparametrizeを使用しており、1つのテスト関数で複数のテストケースを定義できます。
546 |
547 |
548 |
549 | ```python
550 | class TestTodos(TestBase):
551 | ENDPOINT_URI = "/todos"
552 |
553 | """create
554 | """
555 |
556 | @pytest.mark.parametrize(
557 | ["data_in", "expected_status", "expected_data", "expected_error"],
558 | [
559 | pytest.param(
560 | TodoCreate(title="test-create-title", description="test-create-description").model_dump(by_alias=True),
561 | status.HTTP_200_OK,
562 | {"title": "test-create-title", "description": "test-create-description"},
563 | None,
564 | id="success",
565 | ),
566 | pytest.param(
567 | TodoCreate(title="test-create-title", description="test-create-description").model_dump(by_alias=True),
568 | status.HTTP_200_OK,
569 | {"title": "test-create-title", "description": "test-create-description"},
570 | None,
571 | id="any-test-case",
572 | )
573 | ],
574 | )
575 | def test_create(
576 | self,
577 | authed_client: Client,
578 | data_in: dict,
579 | expected_status: int,
580 | expected_data: Optional[dict],
581 | expected_error: Optional[dict],
582 | ) -> None:
583 | self.assert_create(
584 | client=authed_client,
585 | data_in=data_in,
586 | expected_status=expected_status,
587 | expected_data=expected_data,
588 | )
589 | ```
590 |
591 | ## ログの集中管理(Sentry log management)
592 |
593 | .env ファイルの SENTRY_SDK_DNS を設定すると、error 以上の logging が発生した場合に
594 | sentry に自動的に logging されます。
595 |
596 | ## DB マイグレーション(DB migrations)
597 |
598 | alembic/versions.py にマイグレーション情報を記述すると、DB マイグレーション(移行)を実施することができます。
599 | 以下を実行することで、models の定義と実際の DB との差分から自動的にマイグレーションファイルを作成できます。
600 |
601 | ```bash
602 | make makemigrations m="any-migration-description-message"
603 | ```
604 |
605 | 以下を実行することで、マイグレーションが実行できます。
606 |
607 | ```bash
608 | make migrate
609 | ```
610 |
611 | ## fastapi-debug-toolbar
612 |
613 | .env ファイルにて DEBUG=true を指定すると、Swaggar 画面から debug-toolbar が起動できます。
614 |
615 | SQLAlchemy のクエリや Request など、django-debug-toolbar と同等の内容が確認できます。
616 |
617 | ## Linter
618 | ruffというrustで構築された高速なLinterを使用しています。
619 | pre-commitで実行することを想定しています。
620 |
621 | ## CI/CD
622 |
623 | push 時に、Github Actions を使用して、ECS に自動デプロイを行うためのサンプルを記述しています。
624 | 以下に AWS の設定情報等をセットします。
625 |
626 | ※予め、AWS にて ECS 環境を構築しておく必要があります。
627 |
628 | .aws/ecs-task-definition.json
629 | .github/workflow/aws.yml
630 |
631 | ## Elasticsearch
632 |
633 | 実験的に Elasticsearch の docker-compose.yml も定義しています。
634 | FastAPI との連携は未対応のため、別途対応予定です。
635 |
636 | # インストール&使い方(Installations & How to use)
637 |
638 | ### .env ファイルを準備
639 |
640 | .env.example を.env にリネームしてください。
641 |
642 | ### Rye のインストール
643 |
644 | 以下のコマンドで、Rye をローカル PC にインストールします。
645 |
646 | ```bash
647 | curl -sSf https://rye-up.com/get | bash
648 | ```
649 |
650 |
651 | ### 依存パッケージのインストール
652 |
653 | 依存パッケージをローカル PC にインストールします。
654 | Appの実際の動作はDockerコンテナ内で行われますが、VSCODEのインタープリター設定で使用するために、ローカルPCにもパッケージをインストールします。
655 |
656 | ```
657 | rye sync
658 | ```
659 |
660 | ### docker コンテナのビルド & 起動
661 |
662 | ```
663 | docker compose up --build
664 | ```
665 |
666 | ### Web コンテナ内に入る
667 |
668 | 以下のいずれかのコマンドで Web コンテナ内に入ることができます。
669 |
670 | ```bash
671 | docker compose run --rm web bash
672 | ```
673 |
674 | or
675 |
676 | Linux or macOS only
677 |
678 | ```bash
679 | make docker-run
680 | ```
681 |
682 | ### DB 初期化、migration、seed 投入
683 |
684 | コンテナ内で以下を実行することで、DB の初期化、migrate、seed データ投入までの一連の処理を一括で行うことができます。
685 |
686 | ```bash
687 | make init-db
688 | ```
689 |
690 | ## API 管理画面(OpenAPI)表示
691 |
692 | ローカル環境
693 |
694 | ```
695 | http://localhost:8888/docs
696 | ```
697 |
698 | Debug モード(F5 押下)で起動した場合
699 | ※Debug モードの場合は、ブレークポイントでローカル変数を確認できます。
700 |
701 | ```
702 | http://localhost:8889/docs
703 | ```
704 |
705 | ## pre-commit
706 | commit前にlinter等のチェックを自動で行う場合は,pre-commitをインストール後に、以下コマンドでpre-commitを有効化することで、commit時に自動的にチェックができるようになります。
707 |
708 | ```bash
709 | pre-commit install
710 | ```
711 |
712 | ## フロントエンドサンプル(Next.js)
713 |
714 | フロントエンドから API を呼び出すサンプルを`/fontend_sample`に記述しています。
715 |
716 | 以下のコマンドで module をインストールできます。
717 |
718 | ```bash
719 | cd /fontend_sample
720 | npm ci
721 | ```
722 |
723 | 以下のコマンドで、Next サーバーを立ち上げることができます。
724 |
725 | ```bash
726 | npm run dev
727 | ```
728 |
729 | ```
730 | http://localhost:3000
731 | ```
732 |
733 | # デプロイ(Deploy to heroku)
734 |
735 | heroku-cli を使用した heroku へのデプロイ方法を紹介します。
736 | github からの自動デプロイは heroku 側のセキュリティ問題により停止されているので、手動で heroku に push します。
737 |
738 | ## heroku-cli のインストール
739 |
740 | 以下を参考にインストール
741 |
742 | https://devcenter.heroku.com/articles/heroku-cli#install-the-heroku-cli
743 |
744 | ## app の作成
745 |
746 | APPNAME は任意の名称を指定(全ユーザーでユニークである必要があります)
747 |
748 | ```
749 | heroku create APPNAME
750 | ```
751 |
752 | ## git に heroku のリモートリポジトリをセット
753 |
754 | ```
755 | heroku git:remote --app APPNAME
756 | ```
757 |
758 | ## push
759 |
760 | ```
761 | git push heroku master
762 | ```
763 |
764 | ## heroku-cli に heroku-config をインストール
765 |
766 | 本リポジトリでは、.env ファイル経由で設定を読み込んでいるため
767 | heroku でも.env ファイルを有効にする必要があります。
768 |
769 | ```bash
770 | heroku plugins:install heroku-config
771 | ```
772 |
773 | ## .env ファイルを push
774 |
775 | ```bash
776 | heroku config:push
777 | ```
778 |
779 | ## heroku 再起動
780 |
781 | ```bash
782 | heroku restart
783 | ```
784 |
785 | # ライセンス(License)
786 |
787 | https://choosealicense.com/licenses/mit/
788 |
--------------------------------------------------------------------------------