【SwiftUI × FastAPI】サンプルアプリ作成

# はじめに
普段はWebエンジニアをしているのですが、
ネイティブアプリ化を想定しているNext製WebアプリがありXcodeに手を染めました。

その時の備忘についてまとめたのが今回の内容です。

# 完成系
![スクリーンショット 2023-07-23 0.57.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568351/efc566e0-f964-dcbc-ec8e-21f9035a4e06.png)

- DBに登録されているデータをAPIで受け取って表示します
- ボトムバーのナビゲーションを利用してページ切り替えができます
- 画面上部のソートと検索ボタンは飾りです

# バックエンド（FastAPI）
Dockerを使用して環境構築します。
FastAPIの経験がある方は、ざっとモデルを確認して合わせていただくだけで大丈夫です。

## プロジェクトディレクトリ作成
```
mkdir serverside
```

以降、serversideディレクトリをルートしてファイルのパスを記載します。
ファイルだけでなく、無いディレクトリは都度作成してください。

## Docker
```docker/api/Dockerfile.dev
FROM python:3.11-slim

RUN apt-get update \
    && apt-get -y install gcc libmariadb-dev \
    && apt-get clean

ENV TZ="Asia/Tokyo"
RUN echo $TZ > /etc/timezone

WORKDIR /workspace
COPY requirements.txt .

RUN pip install --upgrade pip && pip install -r requirements.txt
```

```docker/db/my.conf
[mysqld]
default-time-zone='Asia/Tokyo'
```

```docker/api/requirements.txt
alembic==1.11.1
fastapi==0.99.1
mysqlclient==2.1.1
pydantic==1.10.11
python-dotenv==1.0.0
SQLAlchemy==2.0.18
uvicorn==0.22.0
```

```docker-compose.yml
version: "3.8"

services:
    db:
        image: mariadb:latest
        container_name: mariadb
        restart: always
        environment:
            MYSQL_ROOT_PASSWORD: root_password
            MYSQL_DATABASE: test_database
            MYSQL_USER: test_user
            MYSQL_PASSWORD: password
        ports:
            - 3306:3306
        volumes:
            - db_data:/var/lib/mysql
            - ./docker/db/conf.d:/etc/mysql/conf.d
        networks:
            - backend

    fastapi:
        build:
            context: ./docker/api
            dockerfile: Dockerfile.dev
        container_name: fastapi
        environment:
            PYTHONPATH: /workspace/app
        volumes:
            - ./app:/workspace/app
            - ./scripts:/workspace/scripts
            - ./.env:/workspace/.env
        working_dir: /workspace
        command: bash -c "./scripts/run.sh"
        ports:
            - 8000:8000
        depends_on:
            - db
        networks:
            - backend

networks:
    backend:

volumes:
    db_data:
```

## 起動用スクリプト
```scripts/run.sh
#!/bin/bash
cd /workspace/app/db
alembic upgrade head
cd /workspace
uvicorn app.main:app --reload --port=8000 --host=0.0.0.0
```

## 環境変数
次に環境変数を設定します。

```env.py
import os

from dotenv import load_dotenv

dotenv_path = os.path.join(os.path.dirname(__file__), '../.env')
load_dotenv(dotenv_path)

DB_USER = os.getenv('MYSQL_USER')
DB_PASSWORD = os.getenv('MYSQL_PASSWORD')
DB_ROOT_PASSWORD = os.getenv('MYSQL_ROOT_PASSWORD')
DB_HOST = os.getenv('MYSQL_HOST')
DB_NAME = os.getenv('MYSQL_DATABASE')
```

```.env
MYSQL_ROOT_PASSWORD=root_password
MYSQL_DATABASE=test_db
MYSQL_USER=test_user
MYSQL_PASSWORD=password
MYSQL_HOST=db
```

## DB作成

DB接続に使用する関数などを作成します。
```app/database.py
from sqlalchemy.engine import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, scoped_session, sessionmaker

from env import DB_HOST, DB_NAME, DB_PASSWORD, DB_USER


DATABASE = "mysql://%s:%s@%s/%s?charset=utf8mb4" % (
    DB_USER,
    DB_PASSWORD,
    DB_HOST,
    DB_NAME,
)
engine = create_engine(
    DATABASE,
    echo=True,
    pool_size=20,
    max_overflow=100
)
Base = declarative_base()

SessionLocal = sessionmaker(
    autocommit=False,
    autoflush=False,
    bind=engine
)

ScopedSessionLocal = scoped_session(SessionLocal)

def get_db():
    db = SessionLocal()
    try:
        yield db
        db.commit()
    except Exception as e:
        print(e)
        db.rollback()
    finally:
        db.close()
```

モデルを作成します。
```app/models/test_items.py
from database import Base
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship

from .mixins import TimestampMixin


class TestItems(Base, TimestampMixin):
    __tablename__ = "test_items"
    __table_args__ = {"comment": "テストアイテム"}
    id = Column(Integer, autoincrement=True, primary_key=True, comment="テストアイテムID")
    field1 = Column("field1", String(length=20), nullable=False, comment="field1")
    tield2 = Column("tield2", String(length=20), nullable=False, comment="tield2")
```

```app/models/mixins.py
from sqlalchemy import Column, text
from sqlalchemy.dialects.mysql import TIMESTAMP as Timestamp


class TimestampMixin(object):
    created_at = Column(
        Timestamp,
        nullable=False,
        server_default=text("current_timestamp"),
        comment="作成日時",
    )
    updated_at = Column(
        Timestamp,
        nullable=False,
        server_default=text("current_timestamp on update current_timestamp"),
        comment="更新日時",
    )
```

alembicの初期化と設定をします。
```bash
docker-compose build
docker-compose up -d
docker exec -it fastapi bash
```

```bash
/workspace# cd app/db
/workspace/app/db# alembic init migrations
```

「追加」とコメントアウトがある箇所だけ追加します。
```app/db/migrations/env.py
from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

from database import Base  # 追加
from env import DB_HOST, DB_NAME, DB_PASSWORD, DB_USER  # 追加
import models.test_items  # 追加


# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

DATABASE = "mysql://%s:%s@%s/%s?charset=utf8mb4" % (  # 追加
    DB_USER,
    DB_PASSWORD,
    DB_HOST,
    DB_NAME,
)

config.set_main_option('sqlalchemy.url', DATABASE)


# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata  # 追加

# other values from the config, defined by the needs of env.py,
...以下省略
```

以下をコメントアウトします。
```app/db/alembic.ini
# sqlalchemy.url = driver://user:pass@localhost/dbname
```

マイグレーションファイルを作成・マイグレート実行します。
```bash
/workspace/app/db# alembic revision --autogenerate -m "first_migration"
/workspace/app/db# alembic upgrade head
```

## エンドポイント~ロジック作成 

メインスクリプトでルーティングとCORS設定（本番で["*"]はNGです。）
```app/main.py
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware

from routers.test_items import router as test_item_router

app = FastAPI()

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

router = APIRouter()
router.include_router(
    test_item_router,
    prefix='/api/v1/test',
    tags=['test_items']
)

app.include_router(router)
```

入出力の型定義
```app/schemes/test_items.py
from typing import List

from pydantic import BaseModel


class TestItemResponseItem(BaseModel):
    id: int | None
    field1: str
    field2: str

    class Config:
        orm_mode = True
        schema_extra = {
            "example": {
                "id" : "1",
                "field1": "hogehoge",
                "field2": "fugafuga",
            },
        }


class TestItemResponse(BaseModel):
    results: List[TestItemResponseItem]

    class Config:
        orm_mode = True
        schema_extra = {
            "example": {
                "results" : [TestItemResponseItem.Config.schema_extra["example"]]
            },
        }


class TestItemCreate(BaseModel):
    field1: str
    field2: str

    class Config:
        schema_extra = {
            "example": {
                "field1": "hogehoge",
                "field2": "fugafuga",
            },
        }
```

ルーター
```app/routers/test_items.py
from typing import List

from cruds import test_items as crud
from database import get_db
from fastapi import APIRouter, Depends
from schemas.test_items import TestItemResponse, TestItemCreate, TestItemResponseItem
from sqlalchemy.orm import Session

router = APIRouter()


@router.get('', response_model=TestItemResponse)
async def read_test_items(db: Session = Depends(get_db)):
    return crud.read_test_items(db=db)


@router.post('', response_model=TestItemResponseItem)
async def create_test_item(test_item: TestItemCreate, db: Session = Depends(get_db)):
    return crud.create_test_item(test_item=test_item, db=db)
```

ロジック
```app/cruds/test_items.py
from typing import List

from models.test_items import TestItem
from schemas.test_items import TestItemCreate
from sqlalchemy.orm import Session


def read_test_items(db: Session):
    """全テストアイテムを取得する

    Args:
        db (Session): データベース接続のセッションオブジェクト

    Returns:
        (List[TestItem]): テストアイテムオブジェクトのリスト
    """
    test_items = db.query(TestItem).all()
    return {
        "results": test_items
    }


def create_test_item(db: Session, test_item: TestItemCreate) -> TestItem:
    """テストアイテムを作成する

    Args:
        db (Session): データベース接続のセッションオブジェクト
        test_item (TestItemCreate): テストアイテム作成スキーマ

    Returns:
        new_test_item (TestItem): 作成されたテストアイテムオブジェクト
    """
    new_test_item = TestItem(
        field1=test_item.field1,
        field2=test_item.field2
    )
    db.add(new_test_item)
    # db.refresh(new_test_item)
    return new_test_item
```


## 表示する用のデータ作成
FastAPI標準のSwaggerからデータをPOSTして作成します。
なお、シーダーの作り方については別途記事で紹介する予定です。

以下をブラウザで表示します。
http://localhost:8000/docs

POSTメソッドの「Try it out」を選択し「Execute」を実行します。
![スクリーンショット 2023-07-23 0.30.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568351/72224f3b-bd7e-6d2a-1355-16ff96c72d20.png)
するとexampleのデータがDBに登録されます。

試しにGETメソッドを実行して登録したデータを表示してみてください。

バックエンドの構築は以上となります。
最低限の入出力はできているため、あとはご自由にカスタマイズしてみてください。






# 画面（SwiftUI）
Xcodeはインストール済みとします。

## プロジェクト作成

「Create a new Xcode project」を選択します。
![スクリーンショット 2023-07-22 23.21.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568351/9131813b-66d3-e59a-354a-119fe8923d21.png)

「iOS」タブで「APP」を選択し、Nextに進みます。
![スクリーンショット 2023-07-22 23.21.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568351/d7af3d29-57e9-a90d-f967-1fbfa53471d1.png)

「Product Name」「Organization Identifier」を入力し、Nextに進みます。
![スクリーンショット 2023-07-22 23.22.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/568351/3c6f6e17-1037-5a3a-386c-5081be49c98d.png)

あとは保存したいディレクトリを指定すればプロジェクトの雛形が出来上がります。

## 全体コード
```ContentView.swift
import SwiftUI


enum ContentState {
    case first
    case second
    case third
}


struct Response: Codable {
   var results: [Result]
}

struct Result: Codable {
    var id: Int
    var field1: String?
    var field2: String?
}


struct FirstView: View {
    @Binding var state: ContentState
    @Binding var results: [Result]
    var body: some View {
        List(results, id: \.id) { item in
            VStack(alignment: .leading, spacing: 10) {
                Text("No: \(String(item.id))").font(.headline).fontWeight(.bold).foregroundColor(.orange)
                Text("field1: \(item.field1 ?? "error")")
                Text("field2: \(item.field2 ?? "error")")
            }
        }
        .navigationTitle("ページ１")
    }
}


struct SecondView: View {
    @Binding var state: ContentState
    @Binding var results: [Result]
    var body: some View {
        List(results, id: \.id) { item in
            VStack(alignment: .leading, spacing: 10) {
                Text("No: \(String(item.id))").font(.headline).fontWeight(.bold).foregroundColor(.orange)
                Text("field1: \(item.field1 ?? "error")")
                Text("field2: \(item.field2 ?? "error")")
            }
        }
        .navigationTitle("ページ２")
    }
}


struct ThirdView: View {
    @Binding var state: ContentState
    @Binding var results: [Result]
    var body: some View {
        Text("hogehoge")
        .navigationTitle("ThirdView")
    }
}


struct ContentView: View {
    @State var state: ContentState = .first
    @State var results = [Result]()
    var body: some View {
        NavigationView {
            ZStack {
                switch state {
                    case .first:
                        FirstView(
                            state: $state,
                            results: $results
                        )
                    case .second:
                        SecondView(
                            state: $state,
                            results: $results
                        )
                    case .third:
                        ThirdView(
                            state: $state,
                            results: $results
                        )
                }
            }
            .toolbar {
                if state == .first || state == .second {
                    /// ナビゲーションバー左
                    ToolbarItem(placement: .navigationBarLeading){
                        Button(action: {}) {
                            Image(systemName: "arrow.up.arrow.down")
                            Text("Sort")
                        }
                    }
                    /// ナビゲーションバー右
                    ToolbarItem(placement: .navigationBarTrailing){
                        Button(action: {}) {
                            Image(systemName: "magnifyingglass")
                            Text("Search")
                        }
                    }
                }
                /// ボトムバー
                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: {
                        state = .first
                    }) {
                        VStack(alignment: .center, spacing: 0) {
                            Image(systemName: "circle.circle.fill")
                            Text("TOP")
                        }
                    }
                    Spacer()
                    Button(action: {
                        state = .second
                    }) {
                        VStack(alignment: .center, spacing: 0) {
                            Image(systemName: "circle.circle")
                            Text("SUB")
                        }
                    }
                    Spacer()
                    Button(action: {
                        state = .third
                    }) {
                        VStack(alignment: .center, spacing: 0) {
                            Image(systemName: "person.circle")
                            Text("Account")
                        }
                    }
                }
            }
         }.onAppear(perform: loadData)
    }
    
    func loadData() {
        guard let url = URL(string: "http://localhost:8000/api/v1/test_items") else {
            print("Invalid URL error")
            return
        }
        
        let request = URLRequest(url: url)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data {
                let decoder = JSONDecoder()
                guard let decodedResponse = try? decoder.decode(Response.self, from: data) else {
                    print("Json decode error")
                    return
                }
                DispatchQueue.main.async {
                    results = decodedResponse.results
                }

            } else {
                print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
            }
        }.resume()
        
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

```

先ほどSwaggerで登録したデータが表示されれば成功です。

## ページの切り替えについて
構造体ContentViewで宣言しているボトムバーには３つのボタンがあり、それぞれにactionが設定されています。
```swift
/// ボトムバー
..
ToolbarItemGroup(placement: .bottomBar) {
    Button(action: {
        state = .first
    }) {
..
```
ボタンをタップするとページ名[state]が更新されます。

列挙型ContentStateで定義したページ名を、switch文で表示切り替えを行なっています。
```swift
enum ContentState {
    case first
    case second
    case third
}
..
```

```swift
..
struct ContentView: View {
    @State var state: ContentState = .first
    @State var results = [Result]()
    var body: some View {
        NavigationView {
            ZStack {
                switch state {
                    case .first:
                        FirstView(
                            state: $state,
                            results: $results
                        )
                    case .second:
                        SecondView(
                            state: $state,
                            results: $results
                        )
                    case .third:
                        ThirdView(
                            state: $state,
                            results: $results
                        )
                }
            }
            .toolbar {
..
```

それぞれのページのビューは３つの構造体First~ThirdViewで定義しています。

※今回は練習用なので、FirstViewとSecondViewの内容は同じで、ThirdViewは非常に簡素な内容になっています。

## HTTP通信について
サーバサイドへのリクエストはContentViewのloadDataメソッドで行われます。
```swift
func loadData() {
        guard let url = URL(string: "http://localhost:8000/api/v1/test_items") else {
            print("Invalid URL error")
            return
        }
        
        let request = URLRequest(url: url)
        
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data {
                let decoder = JSONDecoder()
                guard let decodedResponse = try? decoder.decode(Response.self, from: data) else {
                    print("Json decode error")
                    return
                }
                DispatchQueue.main.async {
                    results = decodedResponse.results
                }

            } else {
                print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
            }
        }.resume()
        
    }
```

NavigationViewにメソッドチェーンで定義された`onAppear(perform: loadData)`によりloadData関数がページがロードされるたびに実行されます。

なお、レスポンスのresultsキーの配列要素の型は構造体Codableで定義しています。
```swift
struct Result: Codable {
    var id: Int
    var field1: String?
    var field2: String?
}
```

# 最後に
今回の内容は以上となります。
ご拝読いただきありがとうございました。


# 参考
https://capibara1969.com/2946/

https://zenn.dev/shimakaze_soft/articles/4c0784d9a87751

https://fastapi.tiangolo.com/ja/

https://i-app-tec.com/swiftui/navigationview.html

https://ios-docs.dev/swiftui-navigationview/

https://developer.apple.com/documentation/swiftui/navigationview