0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事は、PAY Advent Calendar 2025 の9日目として書いています。23日目にも書いているのですが、CLIの方もめどがついたので、書きました。

※今回実装したものは、テスト実装なので、くれぐれも本番で使おうとはしないようにお願いします!

PAY.JP について

PAY.JP(ペイドットジェーピー)とは、開発者や事業者向けに、Webサイトやアプリにクレジットカード決済機能を簡単に導入できるオンライン決済代行サービスです。シンプルなAPIと豊富なライブラリが特徴で、PCI DSS準拠の高いセキュリティのもと、業界最低水準の決済手数料(Visa/Mastercard: 2.59%〜)で提供されており、サブスクリプションやプラットフォーム決済、Apple Payなど多様な決済に対応しています。

主な特徴

  • 簡単な導入: 開発者向けのAPIが用意されており、数行のコードでクレジットカード決済機能を実装できます
  • 高いセキュリティ: 国際標準のPCI DSS v4.0に準拠し、カード情報をトークン化することで安全に決済を処理します
  • リーズナブルな手数料: 業界最低水準の決済手数料で、コストを抑えて利用できます
  • 豊富な機能: クレジットカード決済のほか、定期課金、Apple Pay、プラットフォーム決済、通知機能などを統合して利用できます
  • 柔軟な料金プラン: ベンチャー企業や教育機関向けなど、業種や利用規模に応じたプランがあります
  • 資金調達サービス: 売上実績に基づいて資金を調達できる「PAY.JP YELL BANK」も提供しています

PAY.JP の CLI

オフィシャルとして、実装されているようです。

オフィシャル CLI の仕様

オフィシャル CLI に仕様を Gemini に説明してもらいます。
なんか、感じとしてはデバッグ用ツール?

1. 認証とプロファイル設定 (login)

PAY.JP アカウントと CLI を連携させ、操作に必要な認証情報を設定します。

  • 認証の実行: login コマンドを実行すると、ペアリングコードとブラウザ用の URL が表示されます。ブラウザで認証を完了させることで、CLI が安全に API キーを取得します。
  • プロファイルの作成: 認証に成功すると、テストモード用のシークレットキーを含むプロファイルが作成され、ローカルに保存されます。
  • 複数プロファイルの管理: --profile (または -p) フラグを使用して、用途ごとに異なる設定(プロファイル)を使い分けることができます。

2. Webhook イベントのリアルタイム購読と転送 (listen)

PAY.JP で発生したイベント(支払いの作成など)をリアルタイムで受け取り、ローカルの開発環境に転送できます。

  • イベントの購読: 特定のイベント(例: charge.created)を指定して購読するか、デフォルトですべてのイベントを購読します。
  • ローカルへの転送: --forward-to (または -f) フラグを使用して、受け取ったイベントをローカルで起動しているサーバー(例: localhost:3000)や任意の URL に HTTP POST で転送します。
  • ログ出力: 受信したイベントのタイプと、PAY.JP 管理画面へのリンクをターミナルに表示します。
  • 接続の安定性: gRPC ストリーミングを使用してサーバーと通信し、切断時には自動再試行を行います。

3. ログアウト (logout)

CLI に保存された認証情報を削除します。

  • 特定プロファイルの削除: 現在使用している、または指定したプロファイルの認証情報を削除します。
  • 全プロファイルのクリア: --all フラグを使用することで、すべてのプロファイルの認証情報を一括で削除し、ログアウト状態にできます。

4. その他の共通機能

  • 設定のカスタマイズ: 設定ファイル(デフォルトは $HOME/.payjp-cli)を使用して、ベース URL や gRPC サーバーのアドレスを変更できます。
  • バージョン情報の確認: version フラグなどで、CLI のバージョン、ビルド時のコミットハッシュ、ビルド日時を確認できます。

まとめ:主なユースケース

このコマンドを使用することで、**「PAY.JP からの Webhook を、サーバーを公開することなくローカルの開発環境で直接受け取り、バックエンドの処理をデバッグする」**といった作業が容易になります。

個人的に期待するもの

個人的には、APIでできることが全部できるようなものがいいとは思ってます。
アドベントカレンダー23日目用に作成してSDKを使えば良かったのですが、完全に抜けていて、ゼロベースで作成してます。

仕様書

作成した CLI から、Gemini に起こしてもらった仕様書です。

1. システム概要

本システムは、PAY.JP決済サービスのAPIを操作するためのコマンドラインインターフェース(CLI)ツールです。Rust言語で実装されており、支払い、顧客、カード、トークンの各リソースの操作および設定管理機能を提供します。

2. システム構成・アーキテクチャ

システムはモジュール化されており、以下の構成で設計されています。

モジュール 役割 該当ファイル
エントリーポイント 引数のパース、コマンドの実行制御 main.rs
CLIコマンド層 各サブコマンド(charge, customer等)のロジック src/cli/*.rs
APIクライアント層 HTTPリクエストの送信、認証、リトライ処理 src/api/*.rs
モデル層 APIリクエスト/レスポンスのデータ構造 src/models/*.rs
共通機能 設定管理、エラー定義、出力フォーマット config.rs, error.rs, output.rs

3. 主要コンポーネント設計

3.1. APIクライアント (src/api/)

PAY.JP APIとの通信を担当します。

  • PayjpClient: 秘密鍵(Secret Key)を使用し、支払い・顧客・カードの操作を行います。

  • reqwest を利用したブロッキング通信を実装しています。

  • レート制限(429 Too Many Requests)やネットワークエラーに対する指数バックオフ付きリトライ機能を備えています。

  • TokenClient: 公開鍵(Public Key)を使用し、トークンの作成・取得を行います。

3.2. 設定管理 (src/config.rs)

アプリケーションの設定を以下の優先順位で解決します。

  1. コマンドライン引数: 実行時に指定されたオプション。
  2. 環境変数: PAYJP_SECRET_KEY, PAYJP_PUBLIC_KEY 等。
  3. 設定ファイル: config.toml に保存されたプロファイル情報。
  • マルチプロファイル対応: 「default」以外に任意の名前でプロファイルを切り替えて利用可能です。

3.3. 出力制御 (src/output.rs)

ユーザーへの表示形式を制御します。

  • Table形式: tabled クレートを使用し、情報を整理された表形式で表示します。
  • JSON形式: APIのレスポンスをそのままの構造で整形出力します。
  • 日本語対応: エラーメッセージやヒント、日時のJST変換など、日本のユーザー向けにローカライズされています。

4. データモデル (src/models/)

APIで扱う主要なエンティティの定義です。

  • Charge(支払い): 金額、通貨、支払い状態、カード情報、メタデータなどを保持します。
  • Customer(顧客): メールアドレス、説明文、デフォルトカード、保有カードリストなどを保持します。
  • Card(カード): ブランド、下4桁、有効期限、顧客ID、住所情報などを保持します。
  • Token(トークン): 一時的なカード情報を表し、used フラグで使用済みかを確認できます。

5. エラーハンドリング (src/error.rs)

thiserror を使用して独自の AppError を定義しています。

  • Apiエラー: PAY.JP APIから返却されるエラー詳細(ステータスコード、エラータイプ、メッセージ)を保持します。
  • ヒント機能: invalid_number(カード番号不正)や expired_card(期限切れ)などのエラーに対し、具体的な対処法を日本語の「Hint」として提示します。

6. コマンド設計

主要なコマンドライン操作の構成は以下の通りです。

payjp
├── token      # トークン作成・取得 (公開鍵)
├── charge     # 支払い作成・取得・一覧・返金・確定 (秘密鍵)
├── customer   # 顧客作成・取得・一覧・更新・削除 (秘密鍵)
├── card       # カード作成・取得・一覧・更新・削除 (秘密鍵)
└── config     # 設定の表示・保存・パス確認


7. 外部依存関係 (Cargo.toml)

  • clap: コマンドライン引数の解析。
  • reqwest: HTTP通信(blocking機能を使用)。
  • serde / serde_json: JSONのシリアライズ・デシリアライズ。
  • tabled: テーブル形式の出力表示。
  • chrono: 日時操作とタイムゾーン変換。
  • thiserror: エラー型の定義。

投入プロンプト

今回投入したプロンプトは Claude で作成しています。
なんだかんだで、プログラム関係のプロンプト作成は Claude が今のところいい感じがします。
プロンプトなので、必要はないのですが、絵文字とか入ってるあたりかわいいですよね。

# PAY.JP CLI - Rust 実装プロンプト

## 🎯 プロジェクト概要

PAY.JP 決済サービスの API を操作する CLI ツールを Rust でゼロベースから実装する。
既存ライブラリへの依存なしに、API 仕様から直接構築する。

## 📦 技術スタック
```toml
[package]
name = "payjp-cli"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
tokio = { version = "1", features = ["full"] }
thiserror = "2"
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
tabled = "0.17"          # テーブル表示
colored = "2"            # カラー出力
dirs = "5"               # 設定ファイルパス
toml = "0.8"             # 設定ファイル
```

## 🏗️ アーキテクチャ
```
src/
├── main.rs              # エントリーポイント
├── cli/
│   ├── mod.rs
│   ├── charge.rs        # charge サブコマンド
│   ├── customer.rs      # customer サブコマンド
│   └── card.rs          # card サブコマンド
├── api/
│   ├── mod.rs
│   ├── client.rs        # HTTP クライアント(認証・リトライ)
│   ├── charge.rs        # Charge API
│   ├── customer.rs      # Customer API
│   └── card.rs          # Card API
├── models/
│   ├── mod.rs
│   ├── charge.rs        # Charge 構造体
│   ├── customer.rs      # Customer 構造体
│   ├── card.rs          # Card 構造体
│   ├── error.rs         # PAY.JP エラー構造体
│   └── common.rs        # 共通型(List, Metadata等)
├── config.rs            # 設定管理
├── error.rs             # アプリケーションエラー
└── output.rs            # 出力フォーマット(JSON/Table)
```

## 🔐 API 仕様

### 認証
- **方式**: Basic 認証
- **ヘッダー**: `Authorization: Basic {base64(secret_key:)}`
- **注意**: パスワード部分は空文字(コロン後ろ空)
```rust
fn create_auth_header(secret_key: &str) -> String {
    let credentials = format!("{}:", secret_key);
    let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
    format!("Basic {}", encoded)
}
```

### ベース URL
```
https://api.pay.jp/v1
```

### リクエスト形式
- **Content-Type**: `application/x-www-form-urlencoded`
- **レスポンス**: JSON

### レート制限
| ゾーン | テスト環境 | 本番環境 |
|--------|-----------|---------|
| pk     | 2 req/s   | 10 req/s |
| payment| 2 req/s   | 14 req/s |
| sk     | 2 req/s   | 30 req/s |

**429 エラー時のリトライ戦略**:
- 指数バックオフ: 2s → 4s → 8s → 16s → 32s (max)
- ジッター: ±50% のランダム遅延
- 最大リトライ: 5回

## 📋 実装する API エンドポイント

### Charge API
| メソッド | エンドポイント | 説明 |
|---------|---------------|------|
| POST | `/v1/charges` | 支払い作成 |
| GET | `/v1/charges/{id}` | 支払い取得 |
| POST | `/v1/charges/{id}` | 支払い更新 |
| POST | `/v1/charges/{id}/refund` | 返金 |
| POST | `/v1/charges/{id}/capture` | 支払い確定 |
| POST | `/v1/charges/{id}/reauth` | 再オーソリ |
| POST | `/v1/charges/{id}/tds_finish` | 3Dセキュア完了 |
| GET | `/v1/charges` | 支払い一覧 |

**Charge 作成パラメータ**:
- `amount`: 50〜9,999,999 (必須)
- `currency`: "jpy" (必須)
- `card` または `customer`: トークンまたは顧客ID (必須)
- `capture`: bool (デフォルト true)
- `description`: 説明文
- `metadata[key]`: メタデータ (最大20キー)
- `three_d_secure`: bool (3Dセキュア有効化)

### Customer API
| メソッド | エンドポイント | 説明 |
|---------|---------------|------|
| POST | `/v1/customers` | 顧客作成 |
| GET | `/v1/customers/{id}` | 顧客取得 |
| POST | `/v1/customers/{id}` | 顧客更新 |
| DELETE | `/v1/customers/{id}` | 顧客削除 |
| GET | `/v1/customers` | 顧客一覧 |

### Card API
| メソッド | エンドポイント | 説明 |
|---------|---------------|------|
| POST | `/v1/customers/{customer_id}/cards` | カード作成 |
| GET | `/v1/customers/{customer_id}/cards/{id}` | カード取得 |
| POST | `/v1/customers/{customer_id}/cards/{id}` | カード更新 |
| DELETE | `/v1/customers/{customer_id}/cards/{id}` | カード削除 |
| GET | `/v1/customers/{customer_id}/cards` | カード一覧 |

## 📊 データモデル

### Charge
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Charge {
    pub id: String,                    // "ch_xxx"
    pub object: String,                // "charge"
    pub livemode: bool,
    pub created: i64,                  // Unix timestamp
    pub amount: u64,
    pub currency: String,
    pub paid: bool,
    pub captured: bool,
    pub captured_at: Option<i64>,
    pub refunded: bool,
    pub amount_refunded: u64,
    pub refund_reason: Option<String>,
    pub card: Option<Card>,
    pub customer: Option<String>,
    pub description: Option<String>,
    pub failure_code: Option<String>,
    pub failure_message: Option<String>,
    pub metadata: HashMap<String, String>,
    pub three_d_secure_status: Option<String>, // "unverified" | "verified" | "attempted" | "failed"
}
```

### Customer
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Customer {
    pub id: String,                    // "cus_xxx"
    pub object: String,                // "customer"
    pub livemode: bool,
    pub created: i64,
    pub default_card: Option<String>,
    pub cards: CardList,
    pub email: Option<String>,
    pub description: Option<String>,
    pub metadata: HashMap<String, String>,
}
```

### Card
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct Card {
    pub id: String,                    // "car_xxx"
    pub object: String,                // "card"
    pub created: i64,
    pub name: Option<String>,
    pub last4: String,
    pub exp_month: u8,
    pub exp_year: u16,
    pub brand: String,                 // "Visa" | "MasterCard" | "JCB" | "American Express" | "Diners Club" | "Discover"
    pub fingerprint: String,
    pub country: Option<String>,
    pub address_city: Option<String>,
    pub address_line1: Option<String>,
    pub address_line2: Option<String>,
    pub address_state: Option<String>,
    pub address_zip: Option<String>,
    pub address_zip_check: Option<String>,
    pub cvc_check: Option<String>,
    pub metadata: HashMap<String, String>,
}
```

### Error
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct PayjpError {
    pub error: PayjpErrorDetail,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct PayjpErrorDetail {
    pub status: u16,
    #[serde(rename = "type")]
    pub error_type: String,           // "client_error" | "card_error" | "server_error"
    pub code: Option<String>,
    pub message: String,
    pub param: Option<String>,
}
```

### List レスポンス
```rust
#[derive(Debug, Serialize, Deserialize)]
pub struct List<T> {
    pub object: String,               // "list"
    pub count: u64,
    pub has_more: bool,
    pub url: String,
    pub data: Vec<T>,
}
```

## 🖥️ CLI インターフェース

### グローバルオプション
```
payjp [OPTIONS] <COMMAND>

Options:
  -k, --api-key <KEY>     APIキー(環境変数 PAYJP_SECRET_KEY より優先)
  -o, --output <FORMAT>   出力形式 [json|table] (default: table)
  -v, --verbose           詳細出力
  -h, --help              ヘルプ表示
  -V, --version           バージョン表示

Commands:
  charge    支払い操作
  customer  顧客操作
  card      カード操作
  config    設定管理
```

### Charge サブコマンド
```
payjp charge <COMMAND>

Commands:
  create   支払い作成
  get      支払い取得
  update   支払い更新
  refund   返金
  capture  確定
  list     一覧取得

Examples:
  payjp charge create --amount 3500 --card tok_xxx
  payjp charge create --amount 5000 --customer cus_xxx --capture=false
  payjp charge get ch_xxx
  payjp charge refund ch_xxx --amount 1000
  payjp charge capture ch_xxx
  payjp charge list --limit 20 --since 2024-01-01
```

### Customer サブコマンド
```
payjp customer <COMMAND>

Commands:
  create  顧客作成
  get     顧客取得
  update  顧客更新
  delete  顧客削除
  list    一覧取得

Examples:
  payjp customer create --email user@example.com --card tok_xxx
  payjp customer get cus_xxx
  payjp customer update cus_xxx --email new@example.com
  payjp customer delete cus_xxx
  payjp customer list --limit 10
```

### Card サブコマンド
```
payjp card <COMMAND>

Commands:
  create  カード作成
  get     カード取得
  update  カード更新
  delete  カード削除
  list    一覧取得

Examples:
  payjp card create cus_xxx --card tok_xxx
  payjp card get cus_xxx car_xxx
  payjp card update cus_xxx car_xxx --name "TARO YAMADA"
  payjp card delete cus_xxx car_xxx
  payjp card list cus_xxx
```

## ⚠️ エラーハンドリング

### 主要エラーコード
| コード | 説明 | 対処 |
|-------|------|------|
| `invalid_number` | カード番号不正 | 番号確認を促す |
| `invalid_cvc` | CVC不正 | CVC確認を促す |
| `invalid_expiry_month` | 有効期限月不正 | 1-12の範囲確認 |
| `invalid_expiry_year` | 有効期限年不正 | 将来の年確認 |
| `expired_card` | 有効期限切れ | 別カード使用を促す |
| `card_declined` | カード拒否 | カード会社確認を促す |
| `processing_error` | 処理エラー | リトライ |
| `invalid_api_key` | APIキー不正 | キー確認を促す |
| `over_capacity` | レート制限 | 自動リトライ |
| `three_d_secure_failed` | 3D認証失敗 | 再認証を促す |

### エラー出力例
```
Error: カード決済に失敗しました

  Code:    card_declined
  Message: カード会社に拒否されました
  Param:   card

Hint: カード会社にお問い合わせいただくか、別のカードをお試しください。
```

## 🔧 設定ファイル

### パス
- Linux/macOS: `~/.config/payjp/config.toml`
- Windows: `%APPDATA%\payjp\config.toml`

### フォーマット
```toml
[default]
api_key = "sk_test_xxx"
output = "table"

[production]
api_key = "sk_live_xxx"
output = "json"
```

### 環境変数
- `PAYJP_SECRET_KEY`: APIキー
- `PAYJP_OUTPUT`: 出力形式

### 優先順位
1. コマンドラインオプション
2. 環境変数
3. 設定ファイル

## 🧪 テスト戦略

### テスト用カード番号
| 番号 | 結果 |
|------|------|
| `4242424242424242` | 成功 |
| `4000000000000002` | card_declined |
| `4000000000000069` | expired_card |
| `4000000000000119` | processing_error |

### ユニットテスト
- 各モデルの Serialize/Deserialize
- form-urlencoded エンコーディング
- エラーパース
- リトライロジック

### 統合テスト
- テスト環境での実API呼び出し
- E2Eシナリオ(顧客作成→カード登録→決済→返金)

## 📝 実装の優先順位

### Phase 1: 基盤
- [ ] プロジェクト構造セットアップ
- [ ] HTTP クライアント(認証・リトライ)
- [ ] エラーハンドリング
- [ ] 設定管理

### Phase 2: Charge API
- [ ] charge create
- [ ] charge get
- [ ] charge list
- [ ] charge refund
- [ ] charge capture

### Phase 3: Customer/Card API
- [ ] customer CRUD
- [ ] card CRUD
- [ ] ページネーション対応

### Phase 4: UX改善
- [ ] テーブル出力の整形
- [ ] カラー出力
- [ ] エラーメッセージの日本語化
- [ ] 補完スクリプト生成

### Phase 5: 3Dセキュア
- [ ] three_d_secure オプション
- [ ] tds_finish コマンド

## 💡 実装のヒント

### form-urlencoded エンコーディング
```rust
fn encode_params(params: &[(&str, &str)]) -> String {
    params
        .iter()
        .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
        .collect::<Vec<_>>()
        .join("&")
}

// Metadata のエンコード
// metadata[order_id]=12345 → metadata%5Border_id%5D=12345
```

### ページネーション
```rust
pub struct ListParams {
    pub limit: Option<u8>,      // 1-100, default 10
    pub offset: Option<u64>,    // default 0
    pub since: Option<i64>,     // Unix timestamp
    pub until: Option<i64>,     // Unix timestamp
}
```

### Unix タイムスタンプ表示
```rust
fn format_timestamp(ts: i64) -> String {
    let dt = chrono::DateTime::from_timestamp(ts, 0)
        .unwrap()
        .with_timezone(&chrono::FixedOffset::east_opt(9 * 3600).unwrap()); // JST
    dt.format("%Y-%m-%d %H:%M:%S JST").to_string()
}
```

## 🚀 期待する成果物

1. 完全に動作する CLI バイナリ
2. README.md(使用方法・インストール手順)
3. ユニットテスト・統合テスト
4. GitHub Actions CI 設定
5. Shell 補完スクリプト(bash/zsh/fish)

いざ投入

プログラム作成は、Claude Code on the Web を使用しています。

image.png

できたようですが、SDK作った時にも忘れていたことがあったので、追加しました。
トークン作成機能も実装します。

image.png

テスト

実装できたようなので、手元でテストなどやってみます。

penpen@PC release % ./payjp
PAY.JP CLI - Command line interface for PAY.JP payment service

Usage: payjp [OPTIONS] <COMMAND>

Commands:
  charge    Charge operations (create, get, list, refund, capture)
  customer  Customer operations (create, get, list, update, delete)
  card      Card operations (create, get, list, update, delete)
  token     Token operations (create, get) - uses public key
  config    Configuration management
  help      Print this message or the help of the given subcommand(s)

Options:
  -k, --api-key <API_KEY>        Secret API key (overrides PAYJP_SECRET_KEY environment variable)
      --public-key <PUBLIC_KEY>  Public API key (overrides PAYJP_PUBLIC_KEY environment variable)
  -o, --output <OUTPUT>          Output format [json|table]
  -v, --verbose                  Enable verbose output
  -p, --profile <PROFILE>        Profile to use
  -h, --help                     Print help
  -V, --version                  Print version

penpen@PC release % ./payjp config set \
  --api-key sk_test_000000000000000000000 \
  --public-key pk_test_00000000000000000000000
✓ Secret key set to: sk_t...0000
✓ Public key set to: pk_t...0000

✓ Configuration saved to profile: default

penpen@PC release % ./payjp token create --number 4242424242424242 --exp-month 12 --exp-year 2025 --cvc 123
Token Details
──────────────────────────────────────────────────
  ID:             tok_46757cafa106fd72701f152fcbb0 (改竄済み)
  Used:           No
  Live Mode:      No (Test)
  Created:        2025-12-26 18:48:45 JST

  Card Information:
    Brand:        Visa
    Number:       ****4242
    Expiry:       12/2025
    CVC Check:    passed

penpen@PC release % ./payjp charge create --amount 3500 --card tok_46757cafa106fd72701f152fcbb0
Charge Details
──────────────────────────────────────────────────
  ID:             ch_f012e0cc63cfaf86011bb05aa4711 (改竄済み)
  Amount:         ¥3500
  Paid:           Yes
  Captured:       Yes
  Captured At:    2025-12-26 18:49:25 JST
  Refunded:       No
  Card:           Visa ****4242 (12/2025)
  Live Mode:      No (Test)
  Created:        2025-12-26 18:49:25 JST

問題なく課金できたようですね。
ということで、一旦完成です。

まとめ

アドベントカレンダー的には未来になりますが、SDK を作成した時よりは勝手がわかった感もあり、そこそこ実装もうまくいったような気はします。金融系の場合、テストにテストを重ねてリリースを行うので、こういうバイブオンリーで作成したものを本番で使うかどうかと言われたら、なかなか難しいとこではあります。それでも、テストやレビューを重厚に行えるのであれば、バイブコーディングの可能性もあるのかな?とは思いました。何より、コーディング時間の短さには目を見張るものがあります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?