😎

Docker×FastAPI×React(TypeScript) on AWS ECS【frontend】

commits18 min read

前回のbackend編からの続きです

backend編
正直、フロント側はさっぱりセンスが無い+忘れまくってたりしたのでめちゃくちゃ調べ直した。
あらためて両方できる皆さんすごいわぁ、、(ちなみに僕はバックエンド側もセンスないです)
frontend側に入る前に前回やった内容がちょっと半端だったのでまずはそこをサクッと修正してしまおうと思います。

crud.py

# project/backend/app/api/crud.py
from app.models.pydantic import SummaryPayloadSchema
from app.models.tortoise import TextSummary
from typing import Union, Listiter


async def post(payload: SummaryPayloadSchema) -> int:
    summary = TextSummary(
        summary=payload.summary,
    )
    await summary.save()
    return summary.id


async def get_all() -> List:
    summarys = await TextSummary.all().values()
    return summarys


async def get(id: int) -> Union[dict, None]:
    task = await TextSummary.filter(id=id).first().values()
    if task:
        return task[0]
    return None


async def put(id: int, payload: SummaryPayloadSchema) -> Union[dict, None]:
    task = await TextSummary.filter(id=id).update(
        summary=payload.summary
    )
    if task:
        update_task = await TextSummary.filter(id=id).first().values()
        return update_task[0]
    return None


async def delete(id: int) -> int:
    task = await TextSummary.filter(id=id).first().delete()
    return task

main.py

# project/backend/app/main.py
import logging
import os

from fastapi import FastAPI, HTTPException, Path
from typing import List

from app.api import crud
from app.db import init_db
from fastapi.middleware.cors import CORSMiddleware
from app.models.tortoise import SummarySchema
from app.models.pydantic import SummaryPayloadSchema, SummaryResponseSchema


log = logging.getLogger("uvicorn")


app = FastAPI()

origins = [
    "*",
    "localhost:3000",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)

@app.on_event("startup")
async def startup_event():
    log.info("Starting up...")
    init_db(app)


@app.on_event("shutdown")
async def shutdown_event():
    log.info("Shutting down...")


@app.post("/todos", response_model=SummaryResponseSchema, status_code=201)
async def create_summary(payload: SummaryPayloadSchema) -> SummaryResponseSchema:
    summary_id = await crud.post(payload)

    response_object = {
        "id": summary_id,
        "summary": payload.summary
    }
    return response_object


@app.get("/todos", response_model=List[SummarySchema])
async def read_all_todo() -> List[SummarySchema]:
    return await crud.get_all()


@app.put("/todos/{id}/", response_model=SummarySchema)
async def update_todo(payload: SummaryPayloadSchema, id: int = Path(..., gt=0)) -> SummarySchema:
    todo = await crud.put(id, payload)
    if not todo:
        raise HTTPException(status_code=404,detail="Todos not found")
    return todo


@app.delete("/todos/{id}/", response_model=SummaryResponseSchema)
async def delete_todo(id: int = Path(..., gt=0)) -> SummaryResponseSchema:
    todo = await crud.get(id)
    if not todo:
        raise HTTPException(status_code=404, detail="Todos not found")
    await crud.delete(id)
    return todo

更新と削除用のエンドポイントを追加したのでこれで一旦CRUDの機能を確認しましょう。

http://localhost:8004/docs

確認出来たら一回止めときます。

docker-compose stop

そんじゃあ、frontend側いってみよー

frontendディレクトリを作成して、Dockerfileを用意。

mkdir frontend && cd frontend
touch Dockerfile
# project/frontend/Dockerfile

#pull official base-image
FROM node:16-alpine
WORKDIR /usr/src/

docker-composeも修正していきます。

version: '3.8'

services:
  web:
    build:
      context: ./project/backend
      dockerfile: Dockerfile
    command: uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000
    volumes:
      - ./project/backend:/usr/src/
    environment:
      - DATABASE_URL=postgres://postgres:postgres@web-db:5432/develop
    ports:
      - 8004:8000
    depends_on:
      - web-db

  web-db:
    build:
      context: ./project/backend/db
      dockerfile: Dockerfile
    expose:
      - 5432
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

  front:
    stdin_open: true
    build:
      context: ./project/frontend
      dockerfile: Dockerfile
    volumes:
      - ./project/frontend:/usr/src/
    ports:
      - 3007:3000
    depends_on:
      - web

修正後に以下のコマンドを入力します。

docker-compose build
docker-compose run front sh -c "npx create-react-app app --template typescript"

プロンプトが返ってきたら更に以下を実行しましょう。

docker-compose up -d

コンテナが起動していることを確認後、frontのコンテナに入ります。

docker ps
docker-compose exec front sh

コンテナに入ったらプロジェクトのディレクトリに移動して、ChakraUIをインストールします。
ChakraUI

cd app
npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4

終わったらコンテナから出て、Dockerfileとdocker-compose.ymlを再度編集します。

#pull official base-image
FROM node:16-alpine
WORKDIR /usr/src/app/

#add usr/src/app/node_modules/.bin to $PATH
ENV PATH /usr/src/app/node_modules.bin:$PATH

##install and cache app dependencies
COPY app/package.json .
COPY app/package-lock.json .
#npm install
RUN npm ci
RUN npm install react-scripts@4.0.3 -g --silent
#
#start app
CMD ["npm", "start"]

version: '3.8'

services:
  web:
    build:
      context: ./project/backend
      dockerfile: Dockerfile
    command: uvicorn app.main:app --reload --workers 1 --host 0.0.0.0 --port 8000
    volumes:
      - ./project/backend:/usr/src/
    environment:
      - DATABASE_URL=postgres://postgres:postgres@web-db:5432/develop
    ports:
      - 8004:8000
    depends_on:
      - web-db

  web-db:
    build:
      context: ./project/backend/db
      dockerfile: Dockerfile
    expose:
      - 5432
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

  front:
    stdin_open: true
    build:
      context: ./project/frontend
      dockerfile: Dockerfile
    volumes:
      - ./project/frontend:/usr/src/
      - /usr/src/app/node_modules
    ports:
      - 3007:3000
    depends_on:
      - web

編集後は再度コンテナを立ち上げて、以下のリンク先に飛んだらreactのいつもの画面が確認できます。

docker-compose up -d --build

http://localhost:3007

っしゃあ、画面つくってくぞぉ

まずはグローバルスタイルを設定してしまいます。

mkdir project/frontend/app/src/theme
touch project/frontend/app/src/theme/theme.ts
#project/frontend/app/src/theme/theme.ts
import { extendTheme } from "@chakra-ui/react";

const theme = extendTheme({
    styles:{
        global: {
            body: {
                backgroundColor: "gray.700",
                color: "teal.400",
            }
        }
    }
});

export default theme;

お次は型定義用のファイルを作ります。

mkdir project/frontend/app/src/types && mkdir project/frontend/app/src/types/api
touch project/frontend/app/src/types/api/TodoType.ts
export type TodoType = {
    "id": number;
    "summary": string;
    "created_at": string;
}

続けてComponentという名ばかりのファイルを作ります。
言い訳になりますが、はやく本題のECSの記事を書きたいので1つにまとめてしまいました。
はい。言い訳です。

mkdir project/frontend/app/src/Components
touch project/frontend/app/src/Components/IndexPage.tsx
import React, {ChangeEvent, memo, useCallback, useEffect, useState, ReactNode, VFC} from "react";
import {
    Box,
    Button,
    Divider,
    Flex,
    Heading,
    Input,
    Stack,
    Text,
    Modal,
    ModalOverlay,
    ModalContent,
    useDisclosure, ModalHeader, ModalCloseButton, ModalBody, FormControl, FormLabel, ModalFooter, FormHelperText,
} from "@chakra-ui/react";
import { TodoType } from "../types/api/TodoType";


export const IndexPage: VFC = memo(() => {
    const [todoName, setTodoName] = useState('');
    const [todos, setTodos] = useState<Array<TodoType>>([]);
    const onChangeTask = (e: ChangeEvent<HTMLInputElement>) => setTodoName(e.target.value);

    const getTodos = useCallback(async() => {
        const response: Response = await fetch("http://localhost:8004/todos")
        await response.json()
            .then((r) => {
                setTodos(r)
            })
            .catch(() => {
                alert("undefined get Reasponse...")
            });
    },[]);

    const onClickAddTodo = () => {
        if (todoName === "") return;
        const newTodo = {
            "id": todos.length + 1,
            "summary": todoName
        }
        fetch("http://localhost:8004/todos", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(newTodo)
        }).then(() => {
            getTodos();
        }).catch(() => {
            alert("unknown posted error")
        })
        setTodoName("");
    };

    useEffect( () => {
        getTodos();
    },[getTodos]);


    return (
        <Flex
            align="center"
            color="gray.500"
            height="100vh"
            justify="center"
        >
            <Box
                w="400">
                <Stack spacing={6}>
                    <Heading
                        as="h1"
                        size="lg"
                        textAlign="center"
                    >TodoApp
                    </Heading>
                    <Box w={400}>
                        <Stack>
                            <Input
                                placeholder="Input taskname.."
                                value={todoName}
                                onChange={onChangeTask}
                            />
                            <Button
                                colorScheme="teal"
                                onClick={onClickAddTodo}
                            >Add</Button>
                            <Divider my={"5"}/>
                        </Stack>
                    </Box>
                    {todos.map((todo) => (
                        <Todo
                            key={todo.summary}
                            id={todo.id}
                            item={todo.summary}
                            getTodos={getTodos}
                        />
                    ))}
                </Stack>
            </Box>
        </Flex>
    )
});


type Props = {
    key: string;
    id: number;
    item: string;
    getTodos: () => void;
}

const Todo: VFC<Omit<Props, "key">> = memo((props) => {
    const { id, item, getTodos } = props;

    return (
        <>
            <Box>
                <Text>{item}</Text>
                <UpdateTodo
                    id={id}
                    item={item}
                    getTodos={getTodos}
                >Update</UpdateTodo>
                <DeletedTodo
                    id={id}
                    item={item}
                    getTodos={getTodos}
                >Delete</DeletedTodo>
            </Box>
        </>
    );
});

type UpdateProps = {
    id: number;
    item: string;
    getTodos: () => void;
    children: ReactNode;
}

const UpdateTodo: VFC<UpdateProps> = (props) => {
    const { id, item, getTodos, children } = props;
    const [ todo, setTodo ] = useState(item);
    const { isOpen, onOpen, onClose } = useDisclosure();
    const onClickModalOpen = useCallback(() => onOpen(), [])

    const onChangeTodo = (e: ChangeEvent<HTMLInputElement>) => setTodo(e.target.value);
    const onClickUpdateTodo  = async(id: number, item: string) => {
        await fetch(`http://localhost:8004/todos/${id}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ summary: item })
        })
        onClose();
        getTodos();
    };

    return (
        <>
            <Modal
                isOpen={isOpen}
                onClose={onClose}
                autoFocus={false}
            >
                <ModalOverlay>
                    <ModalContent>
                        <ModalHeader>Task Update</ModalHeader>
                        <ModalCloseButton/>
                        <ModalBody>
                            <Stack>
                                <FormControl>
                                    <FormLabel>Task rename</FormLabel>
                                    <Input
                                        value={todo}
                                        onChange={onChangeTodo}
                                    ></Input>
                                </FormControl>
                            </Stack>
                        </ModalBody>
                        <ModalFooter>
                            <Button onClick={() => onClickUpdateTodo(id, todo)}>Done</Button>
                        </ModalFooter>
                    </ModalContent>
                </ModalOverlay>
            </Modal>
            <Button mr="2" onClick={onClickModalOpen}>{children}</Button>
        </>
    );
};

type DeletedProps = {
    id: number;
    item: string;
    getTodos: () => void;
    children: ReactNode;
}

const DeletedTodo: VFC<DeletedProps> = (props) => {
    const { id, item, getTodos, children } = props;
    const { isOpen, onOpen, onClose } = useDisclosure();
    const onClickModalOpen = useCallback(() => onOpen(), [])

    const onClickDeletedTodo  = async(id: number) => {
        await fetch(`http://localhost:8004/todos/${id}`, {
            method: "DELETE",
            headers: { "Content-Type": "application/json" }
        })
        onClose();
        getTodos();
    };

    return (
        <>
            <Modal
                isOpen={isOpen}
                onClose={onClose}
                autoFocus={false}
            >
                <ModalOverlay>
                    <ModalContent>
                        <ModalHeader>Task Delete</ModalHeader>
                        <ModalCloseButton/>
                        <ModalBody>
                            <Stack>
                                <FormControl>
                                    <FormLabel>this task delete?</FormLabel>
                                    <FormHelperText>{item}</FormHelperText>
                                </FormControl>
                            </Stack>
                        </ModalBody>
                        <ModalFooter>
                            <Button onClick={() => onClickDeletedTodo(id)}>Done</Button>
                        </ModalFooter>
                    </ModalContent>
                </ModalOverlay>
            </Modal>
            <Button onClick={onClickModalOpen}>{children}</Button>
        </>
    );
};


最後にApp.tsxを編集します。

import React from 'react';
import { IndexPage } from "./Components/IndexPage";
import {
  ChakraProvider,
} from "@chakra-ui/react";
import theme from "./theme/theme";


function App() {
  return (
      <>
        <header className="App-header">
          <ChakraProvider theme={theme}>
            <IndexPage/>
          </ChakraProvider>
        </header>
      </>
  );
}

export default App;

再度以下のリンク先に飛んでみます。

http://localhost:3007

※コンパイルエラーとか出て消えないようであれば以下を入力して下さい。

docker-compose up -d --build

今はこんな感じになってるはず

現在のプロジェクト配下

.
├── docker-compose.yml
└── project
    ├── backend
    │   ├── Dockerfile
    │   ├── app
    │   │   ├── __init__.py
    │   │   ├── api
    │   │   │   └── crud.py
    │   │   ├── db.py
    │   │   ├── main.py
    │   │   └── models
    │   │       ├── __init__.py
    │   │       ├── pydantic.py
    │   │       └── tortoise.py
    │   ├── db
    │   │   ├── Dockerfile
    │   │   └── create.sql
    │   ├── entrypoint.sh
    │   └── requirements.txt
    └── frontend
        ├── Dockerfile
        └── app
            ├── README.md
            ├── node_modules
            ├── package-lock.json
            ├── package.json
            ├── public
            │   ├── favicon.ico
            │   ├── index.html
            │   ├── logo192.png
            │   ├── logo512.png
            │   ├── manifest.json
            │   └── robots.txt
            ├── src
            │   ├── App.css
            │   ├── App.test.tsx
            │   ├── App.tsx
            │   ├── Components
            │   │   └── IndexPage.tsx
            │   ├── index.css
            │   ├── index.tsx
            │   ├── logo.svg
            │   ├── react-app-env.d.ts
            │   ├── reportWebVitals.ts
            │   ├── setupTests.ts
            │   ├── theme
            │   │   └── theme.ts
            │   └── types
            │       └── api
            │           └── TodoType.ts
            ├── tsconfig.json
            └── yarn.lock

今回もお疲れさまでした!

ようやく次回AWSだぜ、、

GitHubで編集を提案

Discussion

ログインするとコメントできます