Jetpack Composeで写真を撮影するか画像を選択する

Jetpack Composeを使ったAndroidアプリで、インテントで写真を撮影するか画像を選択して、その画像を表示するアプリを作成します。

カメラの権限を要求する

Jetpack Compose Permissionsをインストールする。

Jetpack Composeのバージョンによって、インストールするバージョンが異なる。
参考:google/accompanist: A collection of extension libraries for Jetpack Compose

  • Compose 1.0 (1.0.x) => 0.20.3
  • Compose 1.1 (1.1.x) => 0.23.1
  • Compose UI 1.2 (1.2.x) => 0.25.1
  • Compose UI 1.3 (1.3.x) => 0.26.1-alpha

今回は、バージョン0.23.1を使用する。

build.gradle(:app)に追加する。

dependencies {
    // 追加
    implementation 'com.google.accompanist:accompanist-permissions:0.23.1'
}

AndroidManifest.xmlを編集して、「uses-permission」を追加する。

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>

    <uses-permission android:name="android.permission.CAMERA"/>

カメラの権限が付与されていなければ、権限を要求する。

val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)

// 注意:バージョンによって書き方が異なる
if (cameraPermissionState.hasPermission) {
    // カメラの権限が付与されているとき
} else {
    // カメラの権限を要求する
    cameraPermissionState.launchPermissionRequest()
}

カメラで撮影した写真を取得する

画像の読み込みに使用するCoilをインストールする。

dependencies {
    // 追加
    implementation 'io.coil-kt:coil-compose:2.1.0'
}

AndroidManifest.xmlを編集して、providerを追加する。

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
    <application ...>

        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_path" />
        </provider>

app/src/main/res/xml/にprovider_path.xmlファイルを追加する。

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <cache-path name="my_images" path="images/" />
</paths>

カメラで撮影した写真ファイルを保存するファイルのURIを取得する関数を作成する。

private fun getImageUri(context: Context): Uri {
    val imagePath = File(context.cacheDir, "images")
    imagePath.mkdirs()

    val date = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
    val file = File(imagePath, "$date.jpg")
    return FileProvider.getUriForFile(
        context,
        context.packageName + ".fileprovider",
        file
    )
}

カメラで撮影した写真を保存する一時的なファイルのURIと、表示する画像ファイルのURIの変数を用意する。

    /**
     * カメラで撮影した写真を保存する一時的なファイルのURI
     */
    var uri: Uri? = null

    /**
     * 表示する画像ファイルのURI
     */
    var imageUri by remember { mutableStateOf<Uri?>(null) }

カメラで写真を撮影したら、表示する画像ファイルのURIを更新する。

    /**
     * カメラを起動して写真を撮影する
     */
    val cameraLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.TakePicture(),
        onResult = { success ->
            if (success) {
                imageUri = uri
            }
        }
    )

写真撮影ボタンを押すとカメラを起動して写真を撮影する。

Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = {
        if (cameraPermissionState.hasPermission) {
            // 撮影した写真の保存場所
            uri = getImageUri(context = context)
            // カメラを起動して写真を撮影する
            cameraLauncher.launch(uri)
        } else {
            // カメラの権限を要求する
            cameraPermissionState.launchPermissionRequest()
        }
    }) {
    Text(text = "写真撮影")
}

写真があれば表示する。

if (imageUri != null) {
    AsyncImage(
        model = imageUri,
        contentDescription = "My Image"
    )
}

ここまでのソースコードは以下のようになる。

import android.content.Context
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MainContent()
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MaterialTheme {
        MainContent()
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainContent() {
    val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
    val context = LocalContext.current

    /**
     * カメラで撮影した写真を保存する一時的なファイルのURI
     */
    var uri: Uri? = null

    /**
     * 表示する画像ファイルのURI
     */
    var imageUri by remember { mutableStateOf<Uri?>(null) }
    val imagePicker =
        rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
            imageUri = uri
        }

    /**
     * カメラを起動して写真を撮影する
     */
    val cameraLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.TakePicture(),
        onResult = { success ->
            if (success) {
                imageUri = uri
            }
        }
    )

    Column(modifier = Modifier.fillMaxSize()) {
        Button(
            modifier = Modifier.fillMaxWidth(),
            onClick = {
                if (cameraPermissionState.hasPermission) {
                    // 撮影した写真の保存場所
                    uri = getImageUri(context = context)
                    // カメラを起動して写真を撮影する
                    cameraLauncher.launch(uri)
                } else {
                    // カメラの権限を要求する
                    cameraPermissionState.launchPermissionRequest()
                }
            }) {
            Text(text = "写真撮影")
        }
        if (imageUri != null) {
            AsyncImage(
                model = imageUri,
                contentDescription = "My Image"
            )
        }
    }
}

/**
 * カメラで撮影した写真ファイルを保存するファイルのURIを取得する
 */
private fun getImageUri(context: Context): Uri {
    val imagePath = File(context.cacheDir, "images")
    imagePath.mkdirs()

    val date = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
    val file = File(imagePath, "$date.jpg")
    return FileProvider.getUriForFile(
        context,
        context.packageName + ".fileprovider",
        file
    )

写真を撮影するか画像を選択する

写真を撮影するか画像を選択するには、Intent.createChooserを使用して、Intentを作成する。

/**
 * 写真を撮影するか画像を選択するインテントを作成する
 */
private fun createChooser(uri: Uri): Intent {
    // ファイルの選択
    val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
        type = "image/*"
    }
    // カメラ撮影
    val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    imageCaptureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri)

    val chooserIntent = Intent.createChooser(getContentIntent, "写真の選択")
    chooserIntent.putExtra(
        Intent.EXTRA_INITIAL_INTENTS,
        arrayOf<Parcelable>(imageCaptureIntent)
    )
    return chooserIntent
}

写真を撮影するか画像を選択するインテントで撮影または選択された写真を受け取る。
注意:個々のコードは自信がない。

    /**
     * 写真を撮影するか画像を選択する
     */
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult(),
        onResult = { result: ActivityResult ->
            if (result.resultCode == Activity.RESULT_OK) {
                imageUri = result.data?.data ?: uri
            }
        }
    )

「写真撮影または選択」ボタンを押したときに、写真を撮影するか画像を選択するインテントを実行する。

Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = {
        if (cameraPermissionState.hasPermission) {
            // カメラの権限が付与されているとき
            val tmpUri = getImageUri(context = context)
            uri = tmpUri
            launcher.launch(createChooser(tmpUri))
        } else {
            // カメラの権限を要求する
            cameraPermissionState.launchPermissionRequest()
        }
    }) {
    Text(text = "写真撮影または選択")
}

ここまでのソースコード。

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.provider.MediaStore
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.content.FileProvider
import coil.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MainContent()
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MaterialTheme {
        MainContent()
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MainContent() {
    val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
    val context = LocalContext.current

    /**
     * カメラで撮影した写真を保存する一時的なファイルのURI
     * 注意:アクティビティが破棄されるとuriの値を失う。対策が必要。
     */
    var uri: Uri? = null

    /**
     * 表示する画像ファイルのURI
     */
    var imageUri by remember { mutableStateOf<Uri?>(null) }

    /**
     * カメラを起動して写真を撮影する
     */
    val cameraLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.TakePicture(),
        onResult = { success ->
            if (success) {
                imageUri = uri
            }
        }
    )

    /**
     * 写真を撮影するか画像を選択する
     */
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult(),
        onResult = { result: ActivityResult ->
            if (result.resultCode == Activity.RESULT_OK) {
                imageUri = result.data?.data ?: uri
            }
        }
    )

    Column(modifier = Modifier.fillMaxSize()) {
        Button(
            modifier = Modifier.fillMaxWidth(),
            onClick = {
                if (cameraPermissionState.hasPermission) {
                    // カメラの権限が付与されているとき
                    val tmpUri = getImageUri(context = context)
                    uri = tmpUri
//                    cameraLauncher.launch(tmpUri)
                    launcher.launch(createChooser(tmpUri))
                } else {
                    // カメラの権限を要求する
                    cameraPermissionState.launchPermissionRequest()
                }
            }) {
            Text(text = "写真撮影または選択")
        }
        if (imageUri != null) {
            AsyncImage(
                model = imageUri,
                contentDescription = "My Image"
            )
        }
    }
}

/**
 * カメラで撮影した写真ファイルを保存するファイルのURIを取得する
 */
private fun getImageUri(context: Context): Uri {
    val imagePath = File(context.cacheDir, "images")
    imagePath.mkdirs()

    val date = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
    val file = File(imagePath, "$date.jpg")
    return FileProvider.getUriForFile(
        context,
        context.packageName + ".fileprovider",
        file
    )
}

/**
 * 写真を撮影するか画像を選択するインテントを作成する
 */
private fun createChooser(uri: Uri): Intent {
    // ファイルの選択
    val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
        type = "image/*"
    }
    // カメラ撮影
    val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
    imageCaptureIntent.putExtra(MediaStore.EXTRA_OUTPUT, uri)

    val chooserIntent = Intent.createChooser(getContentIntent, "写真の選択")
    chooserIntent.putExtra(
        Intent.EXTRA_INITIAL_INTENTS,
        arrayOf<Parcelable>(imageCaptureIntent)
    )
    return chooserIntent
}

Kotlinでリストを分割する

chunked(size)メソッドは、リストを引数sizeを超えないリストのリストに分割します。

結果のリストの最後のリストは、指定されたサイズよりも要素が少ない場合があります。

val list = listOf("A","B","C","D","E")

// リストを2つずつに分割
val subList2 = list.chunked(2)
println(subList2) //=> [[A, B], [C, D], [E]]
// リストを3つずつに分割
val subList3 = list.chunked(3)
println(subList3) //=> [[A, B, C], [D, E]]

OpenAPIを使ってみる(2) Androidアプリ(Kotlin)を作成する

OpenAPIを使って、Androidアプリ(Kotlin)を作成する。

OpenAPIを使ってみる(1) Pythonでテストサーバーを立ち上げる」の続き。

  • 環境
    • macOS Monterey
    • openJDK 11(「brew install openjdk@11」でインストール)
    • openapi-generator-cli 6.0.1(「brew install openapi-generator」でインストール)

前回に作成したOpenAPIのAPIドキュメントとテストサーバーを使って、Androidアプリを作成する。

openapi_sampleフォルダーの下にAndroidアプリを作成する。

openapi_sample/
    openapi/
        openapi.yml
    android_app/


OpenAPI Generatorでソースコードを生成する

openapiフォルダーで以下のコマンドを実行し、Androidのソースコードを生成する。

使用できるオプションを確認する。

openapi-generator config-help -g kotlin

作成するアプリケーショーンが対応するAndroidのバージョンを考慮して、使用するライブラリを選定する。

Retrofitを使用する。

--additional-properties=library=jvm-retrofit2

ソースコードの生成されるフォルダーをsrc/main/kotlinでなく、src/main/javaにする。

--additional-properties=src/main/java

kotlinフォルダーに出力する。

-o kotlin

openapiフォルダー上記のオプションを指定して、ソースコードを生成する。

openapi-generator generate -i openapi.yml -g kotlin -o kotlin --additional-properties=jvm-retrofit2,sourceFolder=src/main/java

kotlin/settings.gradleを編集して、パッケージ名を設定する。

rootProject.name = 'openapi-client'

パッケージを作成する。

cd kotlin
chmod +x gradlew
./gradlew check assemble

以下の場所にファイルが生成される。

kotlin/build/libs/openapi-client-1.0.0.jar

このファイルをAndroidプロジェクトにコピーする。

cp build/libs/openapi-client-1.0.0.jar ../../android_app/app/libs

Android Studioで/libs/openapi-client-1.0.0.jarを右クリックして、「Add As Library…」を選択する。

build.gradle(:app)に追加されるので、「Sync Project with Gradle Files」を実行する。

APIを叩く

ApiClientのインスタンスを生成する。
クラス名はopenapi.ymlのtagsで指定した名前+APIになる。
引数でAPIのベースのURLを指定する。

val usersApi = UsersApi("http://192.168.10.109:8000/v1")

UsersApiのメソッドを呼ぶことでAPIを叩ける。

// ユーザー一覧を取得する
val users = usersApi.listUsers()
// ユーザーを取得する
val user = usersApi.getUserById(id ?: 0)

HTTPアクセスを行うために、AndroidManifest.xmlを編集する。

<manifest 
    ...>
    <uses-permission android:name="android.permission.INTERNET"/> //追加

      <application
          ...
          android:usesCleartextTraffic="true" //追加
      >

サンプルプログラム

「Load Users」ボタンを押すとユーザー一覧を取得し、ユーザーをタップすると詳細を表示する。

package jp.gesource.example.android_app

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import jp.gesource.example.android_app.ui.theme.Android_appTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.openapitools.client.apis.UsersApi
import org.openapitools.client.models.SimpleUser
import org.openapitools.client.models.User

class MainActivity : ComponentActivity() {
    private val viewModel by viewModels<MainScreenViewModel>()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Android_appTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
                    MainScreen(viewModel)
                }
            }
        }
    }
}

class MainScreenViewModel : ViewModel() {
    private var _loading = MutableStateFlow(false)
    val loading: StateFlow<Boolean> = _loading

    private var _users: MutableStateFlow<List<SimpleUser>> = MutableStateFlow(emptyList())
    val users: StateFlow<List<SimpleUser>> = _users

    private var _user = MutableStateFlow<User?>(null)
    val user: StateFlow<User?> = _user

    private val usersApi = UsersApi("http://192.168.10.109:8080/v1")

    fun listUsers() {
        _loading.value = true
        viewModelScope.launch(Dispatchers.IO) {
            try {
                delay(1000L)
                val users = usersApi.listUsers()
                _users.value = users
            } catch (e: Exception) {
                println(e.message)
                _users.value = emptyList()
            }
            _loading.value = false
        }
    }

    fun getUserById(id: Int?) {
        _loading.value = true
        viewModelScope.launch(Dispatchers.IO) {
            try {
                delay(1000L)
                val user = usersApi.getUserById(id ?: 0)
                _user.value = user
            } catch (e: Exception) {
                _user.value = null
            }
            _loading.value = false
        }
    }

    fun resetUser() {
        _user.value = null
    }
}

@Composable
fun MainScreen(viewModel: MainScreenViewModel = MainScreenViewModel()) {
    val loading = viewModel.loading.collectAsState()
    val users = viewModel.users.collectAsState()
    val user = viewModel.user.collectAsState()

    val listState = rememberLazyListState()
    val selectedIndex by remember { mutableStateOf(-1) }

    if (loading.value) {
        Box(modifier = Modifier.fillMaxSize()) {
            Text(text = "Loading", modifier = Modifier.align(alignment = Alignment.Center))
        }
    } else {
        if (user.value == null) {
            Column(modifier = Modifier.fillMaxSize()) {
                Button(
                    modifier = Modifier.fillMaxWidth(),
                    onClick = { viewModel.listUsers() }) {
                    Text(text = "Load Users")
                }
                LazyColumn(state = listState) {
                    items(users.value) { user: SimpleUser ->
                        Text(
                            text = "${user.id}: ${user.name}",
                            modifier = Modifier
                                .fillMaxWidth()
                                .selectable(
                                    selected = user.id == selectedIndex,
                                    onClick = { viewModel.getUserById(user.id) }
                                )
                        )
                    }
                }
            }
        } else {
            Column(modifier = Modifier.fillMaxSize()) {
                Button(
                    modifier = Modifier.fillMaxWidth(),
                    onClick = { viewModel.resetUser() }) {
                    Text(text = "Close")
                }
                Text(text = "id : ${user.value?.id}")
                Text(text = "name : ${user.value?.name}")
                Text(text = "birthday : ${user.value?.birthday}")
            }
        }
    }
}

OpenAPIを使ってみる(1) Pythonでテストサーバーを立ち上げる

OpenAPIを使ってPythonでテストようのサーバーを立ち上げるところまでやってみる。

次回は「OpenAPIを使ってみる(2) Androidアプリ(Kotlin)を作成する」。

環境は以下の通り。

  • macOS Monterey(12.5)
  • Python 3.10
    • インストールしていない場合は「brew install python3」でインストールする。

~/development/opemapi_sampleフォルダーを作成する、このフォルダーの中で作業する。

開発環境の構築

Visuau Studio Codeに以下の拡張機能をインストールする。

  • OpenAPI (Swagger) Editor
    • 「command+shift+P」→「Preview Swagger」で、右パネルにプレビューを表示する。
    • 左パネルのOpenAPIを開くと各項目が整理されて表示される。
  • OpenApi Snippets
    • 入力中に補完が効く。
  • openapi-lint
    • 入力間違いの箇所にエラーが表示される

APIの作成

次のようなAPIを作成する。

  • http://192.168.10.109:8080/v1/users
    • ユーザー概要一覧を取得する
    • パラメータ
    • クエリー文字列でpageを受け取る。
    • 返り値
    • {id:ユーザーID, name:ユーザー名}の配列
  • http://192.168.10.109:8080/v1/users/{userId}
    • ユーザー詳細(id,name)を取得する
    • パラメータ
    • URLでユーザーIDを受け取る。
    • 返り値
    • {id:ユーザーID, name:ユーザー名, birthday:生年月日}

openapiフォルダーを作成し、openapi.ymlを作成する。

openapi_sample/
    openapi/
        openapi.yml

openapi.ymlを編集する。

openapi: 3.0.3
info:
  title: "Sample User List App"
  description: This is a sample app
  version: "1.0.0"

servers:
  - url: http://192.168.10.109:8080/v1
    description: Development server

paths:
  /users:
    get:
      summary: ユーザー一覧
      operationId: listUsers
      tags:
        - users
      parameters:
        - in: query
          name: page
          description: ページ番号
          example: 1
          required: false
          schema:
            type: integer
      responses:
        '200':
          description: Return all users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SimpleUser"
        default:
          description: Unexpected error

  /users/{userId}:
    get:
      summary: ユーザーを取得する
      operationId: getUserById
      tags:
        - users
      parameters:
        - in: path
          name: userId
          description: ユーザーID
          example: 1
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: A user
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        default:
          description: Unexpected error

components:
  schemas:
    SimpleUser:
      properties:
        id:
          type: integer
          description: ユーザーID
        name:
          type: string
          description: ユーザー名
    User:
      properties:
        id:
          type: integer
          description: ユーザーID
        name:
          type: string
          description: ユーザー名
        birthday:
          type: string
          description: 生年月日
          example: '2001-02-03'

OpenAPI Generatorの導入

OpenAPI Generatorを導入する。

macOSの場合は、Homebrewを使ってインストールする。

# インストール
% brew install openapi-generator
# 確認
% openapi-generator version
6.0.1

その他のOSの場合は公式サイトに書かれているインストール方法を参考にする。

Python Server

Python Serverを作成する

openapi.ymlのあるフォルダーで、以下のコマンドを実行する。

openapi-generator generate -i openapi.yml -g python-flask -o server

serverフォルダーに、PythonのFlaskを使ったStub Serverのファイル一式が作成される。

Flaskのバージョンが古くエラーになったため、Flaskのバージョンを更新する。

server/requirements.txtを編集する。

Flask == 2.1.3

Stub Serverをを起動する

cd server
pip3 install -r requirements.txt
python3 -m openapi_server

または

cd server
docker build -t openapi_server .
docker run -p 8080:8080 openapi_server

http://192.168.10.109:8080/v1/ui/にアクセスして、API仕様書が表示されることを確認する。

APIの返り値を設定する

server/openapi_server/controllers/users_controller.pyを編集して、返り値を設定する。

from flask import jsonify

def get_user_by_id(user_id):  # noqa: E501
    match user_id:
        case 1:
            return jsonify({'id':1,'name':'Alice','birthday':'2001-02-01'})
        case 2:
            return jsonify({'id':2,'name':'Bob','birthday':'2001-02-02'})
        case 3:
            return jsonify({'id':3,'name':'Carol','birthday':'2001-02-03'})
        case 4:
            return jsonify({'id':4,'name':'Dave','birthday':'2001-02-04'})

    return 'User not found.', 404

def list_users(page=None):  # noqa: E501
    if page == 2:
        return jsonify(({'id':4, 'name':'Dave'}))
    else:
        return jsonify(({'id':1,'name':'Alice'}, {'id':2, 'name':'Bob'}, {'id':3,'name':'Carol'}))

http://192.168.10.109:8080/v1/usersにアクセスして、ユーザー一覧のJSONが返されることを確認する。

http://192.168.10.109:8080/v1/users/1にアクセスして、ユーザーのJSONが返されることを確認する。

開発の準備ができたので、次回はAndroidアプリ(Kotlin)やiOSアプリ(Swift)を作成する。

次回は「OpenAPIを使ってみる(2) Androidアプリ(Kotlin)を作成する」。