中級者向け No.064

APIテストクライアント

URL・メソッド・ヘッダー・ボディを設定してHTTPリクエストを送信しレスポンスを整形表示するツール。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ), requests ⏱️ 制作時間: 30〜90分

1. アプリ概要

URL・メソッド・ヘッダー・ボディを設定してHTTPリクエストを送信しレスポンスを整形表示するツール。

このアプリはnetworkカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)・requests、難易度は ★★☆ です。

Pythonの豊富なライブラリを活用することで、実用的なアプリを短いコードで実装できます。ソースコードをコピーして実行し、仕組みを理解したうえでカスタマイズに挑戦してみてください。

GUIアプリ開発はプログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。変数・関数・クラス・イベント処理などの重要な概念が自然と身につきます。

2. 機能一覧

  • APIテストクライアントのメイン機能
  • 直感的なGUIインターフェース
  • 入力値のバリデーション
  • エラーハンドリング
  • 結果の見やすい表示
  • クリア機能付き

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows・Mac・Linux すべて対応

以下の環境で動作確認しています。

  • Python 3.10 以上
  • OS: Windows 10/11・macOS 12+・Ubuntu 20.04+

インストールが必要なライブラリ

pip install requests

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

app064.py
import tkinter as tk
from tkinter import ttk, messagebox
import urllib.request
import urllib.parse
import json
import threading
import time
from datetime import datetime


class App064:
    """APIテストクライアント"""

    def __init__(self, root):
        self.root = root
        self.root.title("APIテストクライアント")
        self.root.geometry("1100x720")
        self.root.configure(bg="#1e1e1e")
        self._history = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔌 APIテストクライアント",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # リクエスト行
        req_f = tk.Frame(self.root, bg="#2d2d2d", pady=6)
        req_f.pack(fill=tk.X, padx=8, pady=4)

        self.method_var = tk.StringVar(value="GET")
        ttk.Combobox(req_f, textvariable=self.method_var,
                     values=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)

        self.url_var = tk.StringVar(
            value="https://jsonplaceholder.typicode.com/posts/1")
        ttk.Entry(req_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="▶ 送信",
                   command=self._send).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="⏹ キャンセル",
                   command=self._cancel).pack(side=tk.LEFT)
        self._thread = None
        self._cancelled = False

        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: リクエスト設定
        left = tk.Frame(main, bg="#1e1e1e", width=400)
        main.add(left, weight=1)
        req_nb = ttk.Notebook(left)
        req_nb.pack(fill=tk.BOTH, expand=True)

        # ヘッダータブ
        hdr_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hdr_tab, text="ヘッダー")
        self._headers_text = self._make_editor(
            hdr_tab,
            "Content-Type: application/json\n"
            "Accept: application/json\n")

        # ボディタブ
        body_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(body_tab, text="ボディ")
        body_type_f = tk.Frame(body_tab, bg="#252526")
        body_type_f.pack(fill=tk.X)
        self.body_type_var = tk.StringVar(value="JSON")
        for bt in ["JSON", "フォーム", "生テキスト"]:
            tk.Radiobutton(body_type_f, text=bt, variable=self.body_type_var,
                           value=bt, bg="#252526", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#252526").pack(side=tk.LEFT, padx=4)
        self._body_text = self._make_editor(
            body_tab,
            '{\n  "title": "テストpost",\n  "body": "内容",\n  "userId": 1\n}')

        # 認証タブ
        auth_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(auth_tab, text="認証")
        self.auth_type_var = tk.StringVar(value="None")
        for at in ["None", "Bearer Token", "Basic Auth"]:
            tk.Radiobutton(auth_tab, text=at, variable=self.auth_type_var,
                           value=at, bg="#1e1e1e", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#1e1e1e").pack(anchor="w", padx=8, pady=2)
        tk.Label(auth_tab, text="値:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=8)
        self.auth_val_var = tk.StringVar()
        ttk.Entry(auth_tab, textvariable=self.auth_val_var,
                  width=36).pack(anchor="w", padx=8)

        # 履歴タブ
        hist_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hist_tab, text="履歴")
        self.history_list = tk.Listbox(hist_tab, bg="#0d1117", fg="#ccc",
                                        selectbackground="#1f6feb",
                                        font=("Courier New", 8),
                                        relief=tk.FLAT)
        self.history_list.pack(fill=tk.BOTH, expand=True)
        self.history_list.bind("<Double-1>", self._load_history)

        # 右: レスポンス
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)
        res_nb = ttk.Notebook(right)
        res_nb.pack(fill=tk.BOTH, expand=True)

        # レスポンスボディ
        body_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(body_res_tab, text="レスポンス")
        self._response_text = self._make_editor(body_res_tab, "")

        # ヘッダー
        hdr_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(hdr_res_tab, text="レスポンスヘッダー")
        self._res_header_text = self._make_editor(hdr_res_tab, "")

        # ステータスバー
        status_f = tk.Frame(self.root, bg="#007acc", pady=2)
        status_f.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(status_f, text="準備完了",
                                    bg="#007acc", fg="#fff", font=("Arial", 9),
                                    anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.time_lbl = tk.Label(status_f, text="",
                                  bg="#007acc", fg="#fff", font=("Arial", 9))
        self.time_lbl.pack(side=tk.RIGHT, padx=8)

    def _make_editor(self, parent, default=""):
        f = tk.Frame(parent, bg="#1e1e1e")
        f.pack(fill=tk.BOTH, expand=True)
        t = tk.Text(f, bg="#0d1117", fg="#d4d4d4",
                     font=("Courier New", 9), relief=tk.FLAT,
                     insertbackground="#fff", wrap=tk.NONE, undo=True)
        ysb = ttk.Scrollbar(f, command=t.yview)
        xsb = ttk.Scrollbar(f, orient=tk.HORIZONTAL, command=t.xview)
        t.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        t.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)
        if default:
            t.insert("1.0", default)
        return t

    def _send(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        method = self.method_var.get()
        self._cancelled = False
        self.status_lbl.config(text="送信中...")
        self._thread = threading.Thread(
            target=self._do_send, args=(method, url), daemon=True)
        self._thread.start()

    def _cancel(self):
        self._cancelled = True
        self.status_lbl.config(text="キャンセル")

    def _parse_headers(self):
        headers = {}
        for line in self._headers_text.get("1.0", tk.END).splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                headers[k.strip()] = v.strip()
        return headers

    def _do_send(self, method, url):
        start = time.time()
        try:
            headers = self._parse_headers()
            auth_type = self.auth_type_var.get()
            if auth_type == "Bearer Token":
                headers["Authorization"] = f"Bearer {self.auth_val_var.get()}"
            elif auth_type == "Basic Auth":
                import base64
                headers["Authorization"] = (
                    "Basic " + base64.b64encode(
                        self.auth_val_var.get().encode()).decode())

            body_str = self._body_text.get("1.0", tk.END).strip()
            data = body_str.encode("utf-8") if body_str and method != "GET" else None

            req = urllib.request.Request(url, data=data, method=method)
            for k, v in headers.items():
                req.add_header(k, v)

            with urllib.request.urlopen(req, timeout=15) as resp:
                if self._cancelled:
                    return
                status = resp.status
                reason = resp.reason
                resp_headers = dict(resp.getheaders())
                body = resp.read().decode("utf-8", errors="replace")

            elapsed = time.time() - start
            # JSON整形
            try:
                obj = json.loads(body)
                body = json.dumps(obj, ensure_ascii=False, indent=2)
            except Exception:
                pass

            self.root.after(0, self._show_response,
                             status, reason, resp_headers, body, elapsed,
                             method, url)
        except urllib.error.HTTPError as e:
            elapsed = time.time() - start
            try:
                body = e.read().decode("utf-8", errors="replace")
                try:
                    body = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
                except Exception:
                    pass
            except Exception:
                body = str(e)
            self.root.after(0, self._show_response,
                             e.code, e.reason, {}, body, elapsed, method, url)
        except Exception as e:
            elapsed = time.time() - start
            self.root.after(0, self._show_response,
                             0, str(e), {}, str(e), elapsed, method, url)

    def _show_response(self, status, reason, headers, body, elapsed,
                        method, url):
        color = ("#4ec9b0" if 200 <= status < 300
                 else "#f48771" if status >= 400
                 else "#ffd700")
        self.status_lbl.config(
            text=f"HTTP {status} {reason}", fg=color)
        self.time_lbl.config(text=f"{elapsed*1000:.0f}ms")

        self._response_text.delete("1.0", tk.END)
        self._response_text.insert("1.0", body)

        hdr_str = "\n".join(f"{k}: {v}" for k, v in headers.items())
        self._res_header_text.delete("1.0", tk.END)
        self._res_header_text.insert("1.0", hdr_str)

        # 履歴に追加
        entry = f"[{datetime.now().strftime('%H:%M:%S')}] {method} {status} {url}"
        self._history.insert(0, (entry, method, url))
        self.history_list.insert(0, entry)

    def _load_history(self, event=None):
        sel = self.history_list.curselection()
        if not sel:
            return
        _, method, url = self._history[sel[0]]
        self.method_var.set(method)
        self.url_var.set(url)


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

5. コード解説

APIテストクライアントのコードを詳しく解説します。クラスベースの設計で各機能を整理して実装しています。

クラス設計とコンストラクタ

App064クラスにアプリの全機能をまとめています。__init__でウィンドウ設定、_build_ui()でUI構築、process()でメイン処理を担当します。責任の分離により、コードが読みやすくなります。

import tkinter as tk
from tkinter import ttk, messagebox
import urllib.request
import urllib.parse
import json
import threading
import time
from datetime import datetime


class App064:
    """APIテストクライアント"""

    def __init__(self, root):
        self.root = root
        self.root.title("APIテストクライアント")
        self.root.geometry("1100x720")
        self.root.configure(bg="#1e1e1e")
        self._history = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔌 APIテストクライアント",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # リクエスト行
        req_f = tk.Frame(self.root, bg="#2d2d2d", pady=6)
        req_f.pack(fill=tk.X, padx=8, pady=4)

        self.method_var = tk.StringVar(value="GET")
        ttk.Combobox(req_f, textvariable=self.method_var,
                     values=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)

        self.url_var = tk.StringVar(
            value="https://jsonplaceholder.typicode.com/posts/1")
        ttk.Entry(req_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="▶ 送信",
                   command=self._send).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="⏹ キャンセル",
                   command=self._cancel).pack(side=tk.LEFT)
        self._thread = None
        self._cancelled = False

        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: リクエスト設定
        left = tk.Frame(main, bg="#1e1e1e", width=400)
        main.add(left, weight=1)
        req_nb = ttk.Notebook(left)
        req_nb.pack(fill=tk.BOTH, expand=True)

        # ヘッダータブ
        hdr_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hdr_tab, text="ヘッダー")
        self._headers_text = self._make_editor(
            hdr_tab,
            "Content-Type: application/json\n"
            "Accept: application/json\n")

        # ボディタブ
        body_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(body_tab, text="ボディ")
        body_type_f = tk.Frame(body_tab, bg="#252526")
        body_type_f.pack(fill=tk.X)
        self.body_type_var = tk.StringVar(value="JSON")
        for bt in ["JSON", "フォーム", "生テキスト"]:
            tk.Radiobutton(body_type_f, text=bt, variable=self.body_type_var,
                           value=bt, bg="#252526", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#252526").pack(side=tk.LEFT, padx=4)
        self._body_text = self._make_editor(
            body_tab,
            '{\n  "title": "テストpost",\n  "body": "内容",\n  "userId": 1\n}')

        # 認証タブ
        auth_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(auth_tab, text="認証")
        self.auth_type_var = tk.StringVar(value="None")
        for at in ["None", "Bearer Token", "Basic Auth"]:
            tk.Radiobutton(auth_tab, text=at, variable=self.auth_type_var,
                           value=at, bg="#1e1e1e", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#1e1e1e").pack(anchor="w", padx=8, pady=2)
        tk.Label(auth_tab, text="値:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=8)
        self.auth_val_var = tk.StringVar()
        ttk.Entry(auth_tab, textvariable=self.auth_val_var,
                  width=36).pack(anchor="w", padx=8)

        # 履歴タブ
        hist_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hist_tab, text="履歴")
        self.history_list = tk.Listbox(hist_tab, bg="#0d1117", fg="#ccc",
                                        selectbackground="#1f6feb",
                                        font=("Courier New", 8),
                                        relief=tk.FLAT)
        self.history_list.pack(fill=tk.BOTH, expand=True)
        self.history_list.bind("<Double-1>", self._load_history)

        # 右: レスポンス
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)
        res_nb = ttk.Notebook(right)
        res_nb.pack(fill=tk.BOTH, expand=True)

        # レスポンスボディ
        body_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(body_res_tab, text="レスポンス")
        self._response_text = self._make_editor(body_res_tab, "")

        # ヘッダー
        hdr_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(hdr_res_tab, text="レスポンスヘッダー")
        self._res_header_text = self._make_editor(hdr_res_tab, "")

        # ステータスバー
        status_f = tk.Frame(self.root, bg="#007acc", pady=2)
        status_f.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(status_f, text="準備完了",
                                    bg="#007acc", fg="#fff", font=("Arial", 9),
                                    anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.time_lbl = tk.Label(status_f, text="",
                                  bg="#007acc", fg="#fff", font=("Arial", 9))
        self.time_lbl.pack(side=tk.RIGHT, padx=8)

    def _make_editor(self, parent, default=""):
        f = tk.Frame(parent, bg="#1e1e1e")
        f.pack(fill=tk.BOTH, expand=True)
        t = tk.Text(f, bg="#0d1117", fg="#d4d4d4",
                     font=("Courier New", 9), relief=tk.FLAT,
                     insertbackground="#fff", wrap=tk.NONE, undo=True)
        ysb = ttk.Scrollbar(f, command=t.yview)
        xsb = ttk.Scrollbar(f, orient=tk.HORIZONTAL, command=t.xview)
        t.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        t.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)
        if default:
            t.insert("1.0", default)
        return t

    def _send(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        method = self.method_var.get()
        self._cancelled = False
        self.status_lbl.config(text="送信中...")
        self._thread = threading.Thread(
            target=self._do_send, args=(method, url), daemon=True)
        self._thread.start()

    def _cancel(self):
        self._cancelled = True
        self.status_lbl.config(text="キャンセル")

    def _parse_headers(self):
        headers = {}
        for line in self._headers_text.get("1.0", tk.END).splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                headers[k.strip()] = v.strip()
        return headers

    def _do_send(self, method, url):
        start = time.time()
        try:
            headers = self._parse_headers()
            auth_type = self.auth_type_var.get()
            if auth_type == "Bearer Token":
                headers["Authorization"] = f"Bearer {self.auth_val_var.get()}"
            elif auth_type == "Basic Auth":
                import base64
                headers["Authorization"] = (
                    "Basic " + base64.b64encode(
                        self.auth_val_var.get().encode()).decode())

            body_str = self._body_text.get("1.0", tk.END).strip()
            data = body_str.encode("utf-8") if body_str and method != "GET" else None

            req = urllib.request.Request(url, data=data, method=method)
            for k, v in headers.items():
                req.add_header(k, v)

            with urllib.request.urlopen(req, timeout=15) as resp:
                if self._cancelled:
                    return
                status = resp.status
                reason = resp.reason
                resp_headers = dict(resp.getheaders())
                body = resp.read().decode("utf-8", errors="replace")

            elapsed = time.time() - start
            # JSON整形
            try:
                obj = json.loads(body)
                body = json.dumps(obj, ensure_ascii=False, indent=2)
            except Exception:
                pass

            self.root.after(0, self._show_response,
                             status, reason, resp_headers, body, elapsed,
                             method, url)
        except urllib.error.HTTPError as e:
            elapsed = time.time() - start
            try:
                body = e.read().decode("utf-8", errors="replace")
                try:
                    body = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
                except Exception:
                    pass
            except Exception:
                body = str(e)
            self.root.after(0, self._show_response,
                             e.code, e.reason, {}, body, elapsed, method, url)
        except Exception as e:
            elapsed = time.time() - start
            self.root.after(0, self._show_response,
                             0, str(e), {}, str(e), elapsed, method, url)

    def _show_response(self, status, reason, headers, body, elapsed,
                        method, url):
        color = ("#4ec9b0" if 200 <= status < 300
                 else "#f48771" if status >= 400
                 else "#ffd700")
        self.status_lbl.config(
            text=f"HTTP {status} {reason}", fg=color)
        self.time_lbl.config(text=f"{elapsed*1000:.0f}ms")

        self._response_text.delete("1.0", tk.END)
        self._response_text.insert("1.0", body)

        hdr_str = "\n".join(f"{k}: {v}" for k, v in headers.items())
        self._res_header_text.delete("1.0", tk.END)
        self._res_header_text.insert("1.0", hdr_str)

        # 履歴に追加
        entry = f"[{datetime.now().strftime('%H:%M:%S')}] {method} {status} {url}"
        self._history.insert(0, (entry, method, url))
        self.history_list.insert(0, entry)

    def _load_history(self, event=None):
        sel = self.history_list.curselection()
        if not sel:
            return
        _, method, url = self._history[sel[0]]
        self.method_var.set(method)
        self.url_var.set(url)


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

UIレイアウトの構築

LabelFrameで入力エリアと結果エリアを視覚的に分けています。pack()で縦に並べ、expand=Trueで結果エリアが画面いっぱいに広がるよう設定しています。

import tkinter as tk
from tkinter import ttk, messagebox
import urllib.request
import urllib.parse
import json
import threading
import time
from datetime import datetime


class App064:
    """APIテストクライアント"""

    def __init__(self, root):
        self.root = root
        self.root.title("APIテストクライアント")
        self.root.geometry("1100x720")
        self.root.configure(bg="#1e1e1e")
        self._history = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔌 APIテストクライアント",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # リクエスト行
        req_f = tk.Frame(self.root, bg="#2d2d2d", pady=6)
        req_f.pack(fill=tk.X, padx=8, pady=4)

        self.method_var = tk.StringVar(value="GET")
        ttk.Combobox(req_f, textvariable=self.method_var,
                     values=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)

        self.url_var = tk.StringVar(
            value="https://jsonplaceholder.typicode.com/posts/1")
        ttk.Entry(req_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="▶ 送信",
                   command=self._send).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="⏹ キャンセル",
                   command=self._cancel).pack(side=tk.LEFT)
        self._thread = None
        self._cancelled = False

        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: リクエスト設定
        left = tk.Frame(main, bg="#1e1e1e", width=400)
        main.add(left, weight=1)
        req_nb = ttk.Notebook(left)
        req_nb.pack(fill=tk.BOTH, expand=True)

        # ヘッダータブ
        hdr_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hdr_tab, text="ヘッダー")
        self._headers_text = self._make_editor(
            hdr_tab,
            "Content-Type: application/json\n"
            "Accept: application/json\n")

        # ボディタブ
        body_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(body_tab, text="ボディ")
        body_type_f = tk.Frame(body_tab, bg="#252526")
        body_type_f.pack(fill=tk.X)
        self.body_type_var = tk.StringVar(value="JSON")
        for bt in ["JSON", "フォーム", "生テキスト"]:
            tk.Radiobutton(body_type_f, text=bt, variable=self.body_type_var,
                           value=bt, bg="#252526", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#252526").pack(side=tk.LEFT, padx=4)
        self._body_text = self._make_editor(
            body_tab,
            '{\n  "title": "テストpost",\n  "body": "内容",\n  "userId": 1\n}')

        # 認証タブ
        auth_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(auth_tab, text="認証")
        self.auth_type_var = tk.StringVar(value="None")
        for at in ["None", "Bearer Token", "Basic Auth"]:
            tk.Radiobutton(auth_tab, text=at, variable=self.auth_type_var,
                           value=at, bg="#1e1e1e", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#1e1e1e").pack(anchor="w", padx=8, pady=2)
        tk.Label(auth_tab, text="値:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=8)
        self.auth_val_var = tk.StringVar()
        ttk.Entry(auth_tab, textvariable=self.auth_val_var,
                  width=36).pack(anchor="w", padx=8)

        # 履歴タブ
        hist_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hist_tab, text="履歴")
        self.history_list = tk.Listbox(hist_tab, bg="#0d1117", fg="#ccc",
                                        selectbackground="#1f6feb",
                                        font=("Courier New", 8),
                                        relief=tk.FLAT)
        self.history_list.pack(fill=tk.BOTH, expand=True)
        self.history_list.bind("<Double-1>", self._load_history)

        # 右: レスポンス
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)
        res_nb = ttk.Notebook(right)
        res_nb.pack(fill=tk.BOTH, expand=True)

        # レスポンスボディ
        body_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(body_res_tab, text="レスポンス")
        self._response_text = self._make_editor(body_res_tab, "")

        # ヘッダー
        hdr_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(hdr_res_tab, text="レスポンスヘッダー")
        self._res_header_text = self._make_editor(hdr_res_tab, "")

        # ステータスバー
        status_f = tk.Frame(self.root, bg="#007acc", pady=2)
        status_f.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(status_f, text="準備完了",
                                    bg="#007acc", fg="#fff", font=("Arial", 9),
                                    anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.time_lbl = tk.Label(status_f, text="",
                                  bg="#007acc", fg="#fff", font=("Arial", 9))
        self.time_lbl.pack(side=tk.RIGHT, padx=8)

    def _make_editor(self, parent, default=""):
        f = tk.Frame(parent, bg="#1e1e1e")
        f.pack(fill=tk.BOTH, expand=True)
        t = tk.Text(f, bg="#0d1117", fg="#d4d4d4",
                     font=("Courier New", 9), relief=tk.FLAT,
                     insertbackground="#fff", wrap=tk.NONE, undo=True)
        ysb = ttk.Scrollbar(f, command=t.yview)
        xsb = ttk.Scrollbar(f, orient=tk.HORIZONTAL, command=t.xview)
        t.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        t.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)
        if default:
            t.insert("1.0", default)
        return t

    def _send(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        method = self.method_var.get()
        self._cancelled = False
        self.status_lbl.config(text="送信中...")
        self._thread = threading.Thread(
            target=self._do_send, args=(method, url), daemon=True)
        self._thread.start()

    def _cancel(self):
        self._cancelled = True
        self.status_lbl.config(text="キャンセル")

    def _parse_headers(self):
        headers = {}
        for line in self._headers_text.get("1.0", tk.END).splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                headers[k.strip()] = v.strip()
        return headers

    def _do_send(self, method, url):
        start = time.time()
        try:
            headers = self._parse_headers()
            auth_type = self.auth_type_var.get()
            if auth_type == "Bearer Token":
                headers["Authorization"] = f"Bearer {self.auth_val_var.get()}"
            elif auth_type == "Basic Auth":
                import base64
                headers["Authorization"] = (
                    "Basic " + base64.b64encode(
                        self.auth_val_var.get().encode()).decode())

            body_str = self._body_text.get("1.0", tk.END).strip()
            data = body_str.encode("utf-8") if body_str and method != "GET" else None

            req = urllib.request.Request(url, data=data, method=method)
            for k, v in headers.items():
                req.add_header(k, v)

            with urllib.request.urlopen(req, timeout=15) as resp:
                if self._cancelled:
                    return
                status = resp.status
                reason = resp.reason
                resp_headers = dict(resp.getheaders())
                body = resp.read().decode("utf-8", errors="replace")

            elapsed = time.time() - start
            # JSON整形
            try:
                obj = json.loads(body)
                body = json.dumps(obj, ensure_ascii=False, indent=2)
            except Exception:
                pass

            self.root.after(0, self._show_response,
                             status, reason, resp_headers, body, elapsed,
                             method, url)
        except urllib.error.HTTPError as e:
            elapsed = time.time() - start
            try:
                body = e.read().decode("utf-8", errors="replace")
                try:
                    body = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
                except Exception:
                    pass
            except Exception:
                body = str(e)
            self.root.after(0, self._show_response,
                             e.code, e.reason, {}, body, elapsed, method, url)
        except Exception as e:
            elapsed = time.time() - start
            self.root.after(0, self._show_response,
                             0, str(e), {}, str(e), elapsed, method, url)

    def _show_response(self, status, reason, headers, body, elapsed,
                        method, url):
        color = ("#4ec9b0" if 200 <= status < 300
                 else "#f48771" if status >= 400
                 else "#ffd700")
        self.status_lbl.config(
            text=f"HTTP {status} {reason}", fg=color)
        self.time_lbl.config(text=f"{elapsed*1000:.0f}ms")

        self._response_text.delete("1.0", tk.END)
        self._response_text.insert("1.0", body)

        hdr_str = "\n".join(f"{k}: {v}" for k, v in headers.items())
        self._res_header_text.delete("1.0", tk.END)
        self._res_header_text.insert("1.0", hdr_str)

        # 履歴に追加
        entry = f"[{datetime.now().strftime('%H:%M:%S')}] {method} {status} {url}"
        self._history.insert(0, (entry, method, url))
        self.history_list.insert(0, entry)

    def _load_history(self, event=None):
        sel = self.history_list.curselection()
        if not sel:
            return
        _, method, url = self._history[sel[0]]
        self.method_var.set(method)
        self.url_var.set(url)


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

イベント処理

ボタンのcommand引数でクリックイベントを、bind('')でEnterキーイベントを処理します。どちらの操作でも同じprocess()が呼ばれ、コードの重複を避けられます。

import tkinter as tk
from tkinter import ttk, messagebox
import urllib.request
import urllib.parse
import json
import threading
import time
from datetime import datetime


class App064:
    """APIテストクライアント"""

    def __init__(self, root):
        self.root = root
        self.root.title("APIテストクライアント")
        self.root.geometry("1100x720")
        self.root.configure(bg="#1e1e1e")
        self._history = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔌 APIテストクライアント",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # リクエスト行
        req_f = tk.Frame(self.root, bg="#2d2d2d", pady=6)
        req_f.pack(fill=tk.X, padx=8, pady=4)

        self.method_var = tk.StringVar(value="GET")
        ttk.Combobox(req_f, textvariable=self.method_var,
                     values=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)

        self.url_var = tk.StringVar(
            value="https://jsonplaceholder.typicode.com/posts/1")
        ttk.Entry(req_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="▶ 送信",
                   command=self._send).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="⏹ キャンセル",
                   command=self._cancel).pack(side=tk.LEFT)
        self._thread = None
        self._cancelled = False

        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: リクエスト設定
        left = tk.Frame(main, bg="#1e1e1e", width=400)
        main.add(left, weight=1)
        req_nb = ttk.Notebook(left)
        req_nb.pack(fill=tk.BOTH, expand=True)

        # ヘッダータブ
        hdr_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hdr_tab, text="ヘッダー")
        self._headers_text = self._make_editor(
            hdr_tab,
            "Content-Type: application/json\n"
            "Accept: application/json\n")

        # ボディタブ
        body_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(body_tab, text="ボディ")
        body_type_f = tk.Frame(body_tab, bg="#252526")
        body_type_f.pack(fill=tk.X)
        self.body_type_var = tk.StringVar(value="JSON")
        for bt in ["JSON", "フォーム", "生テキスト"]:
            tk.Radiobutton(body_type_f, text=bt, variable=self.body_type_var,
                           value=bt, bg="#252526", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#252526").pack(side=tk.LEFT, padx=4)
        self._body_text = self._make_editor(
            body_tab,
            '{\n  "title": "テストpost",\n  "body": "内容",\n  "userId": 1\n}')

        # 認証タブ
        auth_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(auth_tab, text="認証")
        self.auth_type_var = tk.StringVar(value="None")
        for at in ["None", "Bearer Token", "Basic Auth"]:
            tk.Radiobutton(auth_tab, text=at, variable=self.auth_type_var,
                           value=at, bg="#1e1e1e", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#1e1e1e").pack(anchor="w", padx=8, pady=2)
        tk.Label(auth_tab, text="値:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=8)
        self.auth_val_var = tk.StringVar()
        ttk.Entry(auth_tab, textvariable=self.auth_val_var,
                  width=36).pack(anchor="w", padx=8)

        # 履歴タブ
        hist_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hist_tab, text="履歴")
        self.history_list = tk.Listbox(hist_tab, bg="#0d1117", fg="#ccc",
                                        selectbackground="#1f6feb",
                                        font=("Courier New", 8),
                                        relief=tk.FLAT)
        self.history_list.pack(fill=tk.BOTH, expand=True)
        self.history_list.bind("<Double-1>", self._load_history)

        # 右: レスポンス
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)
        res_nb = ttk.Notebook(right)
        res_nb.pack(fill=tk.BOTH, expand=True)

        # レスポンスボディ
        body_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(body_res_tab, text="レスポンス")
        self._response_text = self._make_editor(body_res_tab, "")

        # ヘッダー
        hdr_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(hdr_res_tab, text="レスポンスヘッダー")
        self._res_header_text = self._make_editor(hdr_res_tab, "")

        # ステータスバー
        status_f = tk.Frame(self.root, bg="#007acc", pady=2)
        status_f.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(status_f, text="準備完了",
                                    bg="#007acc", fg="#fff", font=("Arial", 9),
                                    anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.time_lbl = tk.Label(status_f, text="",
                                  bg="#007acc", fg="#fff", font=("Arial", 9))
        self.time_lbl.pack(side=tk.RIGHT, padx=8)

    def _make_editor(self, parent, default=""):
        f = tk.Frame(parent, bg="#1e1e1e")
        f.pack(fill=tk.BOTH, expand=True)
        t = tk.Text(f, bg="#0d1117", fg="#d4d4d4",
                     font=("Courier New", 9), relief=tk.FLAT,
                     insertbackground="#fff", wrap=tk.NONE, undo=True)
        ysb = ttk.Scrollbar(f, command=t.yview)
        xsb = ttk.Scrollbar(f, orient=tk.HORIZONTAL, command=t.xview)
        t.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        t.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)
        if default:
            t.insert("1.0", default)
        return t

    def _send(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        method = self.method_var.get()
        self._cancelled = False
        self.status_lbl.config(text="送信中...")
        self._thread = threading.Thread(
            target=self._do_send, args=(method, url), daemon=True)
        self._thread.start()

    def _cancel(self):
        self._cancelled = True
        self.status_lbl.config(text="キャンセル")

    def _parse_headers(self):
        headers = {}
        for line in self._headers_text.get("1.0", tk.END).splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                headers[k.strip()] = v.strip()
        return headers

    def _do_send(self, method, url):
        start = time.time()
        try:
            headers = self._parse_headers()
            auth_type = self.auth_type_var.get()
            if auth_type == "Bearer Token":
                headers["Authorization"] = f"Bearer {self.auth_val_var.get()}"
            elif auth_type == "Basic Auth":
                import base64
                headers["Authorization"] = (
                    "Basic " + base64.b64encode(
                        self.auth_val_var.get().encode()).decode())

            body_str = self._body_text.get("1.0", tk.END).strip()
            data = body_str.encode("utf-8") if body_str and method != "GET" else None

            req = urllib.request.Request(url, data=data, method=method)
            for k, v in headers.items():
                req.add_header(k, v)

            with urllib.request.urlopen(req, timeout=15) as resp:
                if self._cancelled:
                    return
                status = resp.status
                reason = resp.reason
                resp_headers = dict(resp.getheaders())
                body = resp.read().decode("utf-8", errors="replace")

            elapsed = time.time() - start
            # JSON整形
            try:
                obj = json.loads(body)
                body = json.dumps(obj, ensure_ascii=False, indent=2)
            except Exception:
                pass

            self.root.after(0, self._show_response,
                             status, reason, resp_headers, body, elapsed,
                             method, url)
        except urllib.error.HTTPError as e:
            elapsed = time.time() - start
            try:
                body = e.read().decode("utf-8", errors="replace")
                try:
                    body = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
                except Exception:
                    pass
            except Exception:
                body = str(e)
            self.root.after(0, self._show_response,
                             e.code, e.reason, {}, body, elapsed, method, url)
        except Exception as e:
            elapsed = time.time() - start
            self.root.after(0, self._show_response,
                             0, str(e), {}, str(e), elapsed, method, url)

    def _show_response(self, status, reason, headers, body, elapsed,
                        method, url):
        color = ("#4ec9b0" if 200 <= status < 300
                 else "#f48771" if status >= 400
                 else "#ffd700")
        self.status_lbl.config(
            text=f"HTTP {status} {reason}", fg=color)
        self.time_lbl.config(text=f"{elapsed*1000:.0f}ms")

        self._response_text.delete("1.0", tk.END)
        self._response_text.insert("1.0", body)

        hdr_str = "\n".join(f"{k}: {v}" for k, v in headers.items())
        self._res_header_text.delete("1.0", tk.END)
        self._res_header_text.insert("1.0", hdr_str)

        # 履歴に追加
        entry = f"[{datetime.now().strftime('%H:%M:%S')}] {method} {status} {url}"
        self._history.insert(0, (entry, method, url))
        self.history_list.insert(0, entry)

    def _load_history(self, event=None):
        sel = self.history_list.curselection()
        if not sel:
            return
        _, method, url = self._history[sel[0]]
        self.method_var.set(method)
        self.url_var.set(url)


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

Textウィジェットでの結果表示

tk.Textウィジェットをstate=DISABLED(読み取り専用)で作成し、更新時はNORMALに変更してinsert()で内容を書き込み、再びDISABLEDに戻します。

import tkinter as tk
from tkinter import ttk, messagebox
import urllib.request
import urllib.parse
import json
import threading
import time
from datetime import datetime


class App064:
    """APIテストクライアント"""

    def __init__(self, root):
        self.root = root
        self.root.title("APIテストクライアント")
        self.root.geometry("1100x720")
        self.root.configure(bg="#1e1e1e")
        self._history = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔌 APIテストクライアント",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # リクエスト行
        req_f = tk.Frame(self.root, bg="#2d2d2d", pady=6)
        req_f.pack(fill=tk.X, padx=8, pady=4)

        self.method_var = tk.StringVar(value="GET")
        ttk.Combobox(req_f, textvariable=self.method_var,
                     values=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)

        self.url_var = tk.StringVar(
            value="https://jsonplaceholder.typicode.com/posts/1")
        ttk.Entry(req_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="▶ 送信",
                   command=self._send).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="⏹ キャンセル",
                   command=self._cancel).pack(side=tk.LEFT)
        self._thread = None
        self._cancelled = False

        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: リクエスト設定
        left = tk.Frame(main, bg="#1e1e1e", width=400)
        main.add(left, weight=1)
        req_nb = ttk.Notebook(left)
        req_nb.pack(fill=tk.BOTH, expand=True)

        # ヘッダータブ
        hdr_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hdr_tab, text="ヘッダー")
        self._headers_text = self._make_editor(
            hdr_tab,
            "Content-Type: application/json\n"
            "Accept: application/json\n")

        # ボディタブ
        body_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(body_tab, text="ボディ")
        body_type_f = tk.Frame(body_tab, bg="#252526")
        body_type_f.pack(fill=tk.X)
        self.body_type_var = tk.StringVar(value="JSON")
        for bt in ["JSON", "フォーム", "生テキスト"]:
            tk.Radiobutton(body_type_f, text=bt, variable=self.body_type_var,
                           value=bt, bg="#252526", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#252526").pack(side=tk.LEFT, padx=4)
        self._body_text = self._make_editor(
            body_tab,
            '{\n  "title": "テストpost",\n  "body": "内容",\n  "userId": 1\n}')

        # 認証タブ
        auth_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(auth_tab, text="認証")
        self.auth_type_var = tk.StringVar(value="None")
        for at in ["None", "Bearer Token", "Basic Auth"]:
            tk.Radiobutton(auth_tab, text=at, variable=self.auth_type_var,
                           value=at, bg="#1e1e1e", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#1e1e1e").pack(anchor="w", padx=8, pady=2)
        tk.Label(auth_tab, text="値:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=8)
        self.auth_val_var = tk.StringVar()
        ttk.Entry(auth_tab, textvariable=self.auth_val_var,
                  width=36).pack(anchor="w", padx=8)

        # 履歴タブ
        hist_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hist_tab, text="履歴")
        self.history_list = tk.Listbox(hist_tab, bg="#0d1117", fg="#ccc",
                                        selectbackground="#1f6feb",
                                        font=("Courier New", 8),
                                        relief=tk.FLAT)
        self.history_list.pack(fill=tk.BOTH, expand=True)
        self.history_list.bind("<Double-1>", self._load_history)

        # 右: レスポンス
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)
        res_nb = ttk.Notebook(right)
        res_nb.pack(fill=tk.BOTH, expand=True)

        # レスポンスボディ
        body_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(body_res_tab, text="レスポンス")
        self._response_text = self._make_editor(body_res_tab, "")

        # ヘッダー
        hdr_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(hdr_res_tab, text="レスポンスヘッダー")
        self._res_header_text = self._make_editor(hdr_res_tab, "")

        # ステータスバー
        status_f = tk.Frame(self.root, bg="#007acc", pady=2)
        status_f.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(status_f, text="準備完了",
                                    bg="#007acc", fg="#fff", font=("Arial", 9),
                                    anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.time_lbl = tk.Label(status_f, text="",
                                  bg="#007acc", fg="#fff", font=("Arial", 9))
        self.time_lbl.pack(side=tk.RIGHT, padx=8)

    def _make_editor(self, parent, default=""):
        f = tk.Frame(parent, bg="#1e1e1e")
        f.pack(fill=tk.BOTH, expand=True)
        t = tk.Text(f, bg="#0d1117", fg="#d4d4d4",
                     font=("Courier New", 9), relief=tk.FLAT,
                     insertbackground="#fff", wrap=tk.NONE, undo=True)
        ysb = ttk.Scrollbar(f, command=t.yview)
        xsb = ttk.Scrollbar(f, orient=tk.HORIZONTAL, command=t.xview)
        t.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        t.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)
        if default:
            t.insert("1.0", default)
        return t

    def _send(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        method = self.method_var.get()
        self._cancelled = False
        self.status_lbl.config(text="送信中...")
        self._thread = threading.Thread(
            target=self._do_send, args=(method, url), daemon=True)
        self._thread.start()

    def _cancel(self):
        self._cancelled = True
        self.status_lbl.config(text="キャンセル")

    def _parse_headers(self):
        headers = {}
        for line in self._headers_text.get("1.0", tk.END).splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                headers[k.strip()] = v.strip()
        return headers

    def _do_send(self, method, url):
        start = time.time()
        try:
            headers = self._parse_headers()
            auth_type = self.auth_type_var.get()
            if auth_type == "Bearer Token":
                headers["Authorization"] = f"Bearer {self.auth_val_var.get()}"
            elif auth_type == "Basic Auth":
                import base64
                headers["Authorization"] = (
                    "Basic " + base64.b64encode(
                        self.auth_val_var.get().encode()).decode())

            body_str = self._body_text.get("1.0", tk.END).strip()
            data = body_str.encode("utf-8") if body_str and method != "GET" else None

            req = urllib.request.Request(url, data=data, method=method)
            for k, v in headers.items():
                req.add_header(k, v)

            with urllib.request.urlopen(req, timeout=15) as resp:
                if self._cancelled:
                    return
                status = resp.status
                reason = resp.reason
                resp_headers = dict(resp.getheaders())
                body = resp.read().decode("utf-8", errors="replace")

            elapsed = time.time() - start
            # JSON整形
            try:
                obj = json.loads(body)
                body = json.dumps(obj, ensure_ascii=False, indent=2)
            except Exception:
                pass

            self.root.after(0, self._show_response,
                             status, reason, resp_headers, body, elapsed,
                             method, url)
        except urllib.error.HTTPError as e:
            elapsed = time.time() - start
            try:
                body = e.read().decode("utf-8", errors="replace")
                try:
                    body = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
                except Exception:
                    pass
            except Exception:
                body = str(e)
            self.root.after(0, self._show_response,
                             e.code, e.reason, {}, body, elapsed, method, url)
        except Exception as e:
            elapsed = time.time() - start
            self.root.after(0, self._show_response,
                             0, str(e), {}, str(e), elapsed, method, url)

    def _show_response(self, status, reason, headers, body, elapsed,
                        method, url):
        color = ("#4ec9b0" if 200 <= status < 300
                 else "#f48771" if status >= 400
                 else "#ffd700")
        self.status_lbl.config(
            text=f"HTTP {status} {reason}", fg=color)
        self.time_lbl.config(text=f"{elapsed*1000:.0f}ms")

        self._response_text.delete("1.0", tk.END)
        self._response_text.insert("1.0", body)

        hdr_str = "\n".join(f"{k}: {v}" for k, v in headers.items())
        self._res_header_text.delete("1.0", tk.END)
        self._res_header_text.insert("1.0", hdr_str)

        # 履歴に追加
        entry = f"[{datetime.now().strftime('%H:%M:%S')}] {method} {status} {url}"
        self._history.insert(0, (entry, method, url))
        self.history_list.insert(0, entry)

    def _load_history(self, event=None):
        sel = self.history_list.curselection()
        if not sel:
            return
        _, method, url = self._history[sel[0]]
        self.method_var.set(method)
        self.url_var.set(url)


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

例外処理とエラーハンドリング

try-exceptでValueErrorとExceptionを捕捉し、messagebox.showerror()でエラーメッセージを表示します。予期しないエラーも処理することで、アプリの堅牢性が向上します。

import tkinter as tk
from tkinter import ttk, messagebox
import urllib.request
import urllib.parse
import json
import threading
import time
from datetime import datetime


class App064:
    """APIテストクライアント"""

    def __init__(self, root):
        self.root = root
        self.root.title("APIテストクライアント")
        self.root.geometry("1100x720")
        self.root.configure(bg="#1e1e1e")
        self._history = []
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔌 APIテストクライアント",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        # リクエスト行
        req_f = tk.Frame(self.root, bg="#2d2d2d", pady=6)
        req_f.pack(fill=tk.X, padx=8, pady=4)

        self.method_var = tk.StringVar(value="GET")
        ttk.Combobox(req_f, textvariable=self.method_var,
                     values=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=4)

        self.url_var = tk.StringVar(
            value="https://jsonplaceholder.typicode.com/posts/1")
        ttk.Entry(req_f, textvariable=self.url_var,
                  width=55).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="▶ 送信",
                   command=self._send).pack(side=tk.LEFT, padx=4)
        ttk.Button(req_f, text="⏹ キャンセル",
                   command=self._cancel).pack(side=tk.LEFT)
        self._thread = None
        self._cancelled = False

        main = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: リクエスト設定
        left = tk.Frame(main, bg="#1e1e1e", width=400)
        main.add(left, weight=1)
        req_nb = ttk.Notebook(left)
        req_nb.pack(fill=tk.BOTH, expand=True)

        # ヘッダータブ
        hdr_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hdr_tab, text="ヘッダー")
        self._headers_text = self._make_editor(
            hdr_tab,
            "Content-Type: application/json\n"
            "Accept: application/json\n")

        # ボディタブ
        body_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(body_tab, text="ボディ")
        body_type_f = tk.Frame(body_tab, bg="#252526")
        body_type_f.pack(fill=tk.X)
        self.body_type_var = tk.StringVar(value="JSON")
        for bt in ["JSON", "フォーム", "生テキスト"]:
            tk.Radiobutton(body_type_f, text=bt, variable=self.body_type_var,
                           value=bt, bg="#252526", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#252526").pack(side=tk.LEFT, padx=4)
        self._body_text = self._make_editor(
            body_tab,
            '{\n  "title": "テストpost",\n  "body": "内容",\n  "userId": 1\n}')

        # 認証タブ
        auth_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(auth_tab, text="認証")
        self.auth_type_var = tk.StringVar(value="None")
        for at in ["None", "Bearer Token", "Basic Auth"]:
            tk.Radiobutton(auth_tab, text=at, variable=self.auth_type_var,
                           value=at, bg="#1e1e1e", fg="#ccc",
                           selectcolor="#3c3c3c",
                           activebackground="#1e1e1e").pack(anchor="w", padx=8, pady=2)
        tk.Label(auth_tab, text="値:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=8)
        self.auth_val_var = tk.StringVar()
        ttk.Entry(auth_tab, textvariable=self.auth_val_var,
                  width=36).pack(anchor="w", padx=8)

        # 履歴タブ
        hist_tab = tk.Frame(req_nb, bg="#1e1e1e")
        req_nb.add(hist_tab, text="履歴")
        self.history_list = tk.Listbox(hist_tab, bg="#0d1117", fg="#ccc",
                                        selectbackground="#1f6feb",
                                        font=("Courier New", 8),
                                        relief=tk.FLAT)
        self.history_list.pack(fill=tk.BOTH, expand=True)
        self.history_list.bind("<Double-1>", self._load_history)

        # 右: レスポンス
        right = tk.Frame(main, bg="#1e1e1e")
        main.add(right, weight=1)
        res_nb = ttk.Notebook(right)
        res_nb.pack(fill=tk.BOTH, expand=True)

        # レスポンスボディ
        body_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(body_res_tab, text="レスポンス")
        self._response_text = self._make_editor(body_res_tab, "")

        # ヘッダー
        hdr_res_tab = tk.Frame(res_nb, bg="#1e1e1e")
        res_nb.add(hdr_res_tab, text="レスポンスヘッダー")
        self._res_header_text = self._make_editor(hdr_res_tab, "")

        # ステータスバー
        status_f = tk.Frame(self.root, bg="#007acc", pady=2)
        status_f.pack(fill=tk.X, side=tk.BOTTOM)
        self.status_lbl = tk.Label(status_f, text="準備完了",
                                    bg="#007acc", fg="#fff", font=("Arial", 9),
                                    anchor="w", padx=8)
        self.status_lbl.pack(side=tk.LEFT)
        self.time_lbl = tk.Label(status_f, text="",
                                  bg="#007acc", fg="#fff", font=("Arial", 9))
        self.time_lbl.pack(side=tk.RIGHT, padx=8)

    def _make_editor(self, parent, default=""):
        f = tk.Frame(parent, bg="#1e1e1e")
        f.pack(fill=tk.BOTH, expand=True)
        t = tk.Text(f, bg="#0d1117", fg="#d4d4d4",
                     font=("Courier New", 9), relief=tk.FLAT,
                     insertbackground="#fff", wrap=tk.NONE, undo=True)
        ysb = ttk.Scrollbar(f, command=t.yview)
        xsb = ttk.Scrollbar(f, orient=tk.HORIZONTAL, command=t.xview)
        t.configure(yscrollcommand=ysb.set, xscrollcommand=xsb.set)
        ysb.pack(side=tk.RIGHT, fill=tk.Y)
        t.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        xsb.pack(fill=tk.X)
        if default:
            t.insert("1.0", default)
        return t

    def _send(self):
        url = self.url_var.get().strip()
        if not url:
            messagebox.showwarning("警告", "URLを入力してください")
            return
        method = self.method_var.get()
        self._cancelled = False
        self.status_lbl.config(text="送信中...")
        self._thread = threading.Thread(
            target=self._do_send, args=(method, url), daemon=True)
        self._thread.start()

    def _cancel(self):
        self._cancelled = True
        self.status_lbl.config(text="キャンセル")

    def _parse_headers(self):
        headers = {}
        for line in self._headers_text.get("1.0", tk.END).splitlines():
            if ":" in line:
                k, _, v = line.partition(":")
                headers[k.strip()] = v.strip()
        return headers

    def _do_send(self, method, url):
        start = time.time()
        try:
            headers = self._parse_headers()
            auth_type = self.auth_type_var.get()
            if auth_type == "Bearer Token":
                headers["Authorization"] = f"Bearer {self.auth_val_var.get()}"
            elif auth_type == "Basic Auth":
                import base64
                headers["Authorization"] = (
                    "Basic " + base64.b64encode(
                        self.auth_val_var.get().encode()).decode())

            body_str = self._body_text.get("1.0", tk.END).strip()
            data = body_str.encode("utf-8") if body_str and method != "GET" else None

            req = urllib.request.Request(url, data=data, method=method)
            for k, v in headers.items():
                req.add_header(k, v)

            with urllib.request.urlopen(req, timeout=15) as resp:
                if self._cancelled:
                    return
                status = resp.status
                reason = resp.reason
                resp_headers = dict(resp.getheaders())
                body = resp.read().decode("utf-8", errors="replace")

            elapsed = time.time() - start
            # JSON整形
            try:
                obj = json.loads(body)
                body = json.dumps(obj, ensure_ascii=False, indent=2)
            except Exception:
                pass

            self.root.after(0, self._show_response,
                             status, reason, resp_headers, body, elapsed,
                             method, url)
        except urllib.error.HTTPError as e:
            elapsed = time.time() - start
            try:
                body = e.read().decode("utf-8", errors="replace")
                try:
                    body = json.dumps(json.loads(body), ensure_ascii=False, indent=2)
                except Exception:
                    pass
            except Exception:
                body = str(e)
            self.root.after(0, self._show_response,
                             e.code, e.reason, {}, body, elapsed, method, url)
        except Exception as e:
            elapsed = time.time() - start
            self.root.after(0, self._show_response,
                             0, str(e), {}, str(e), elapsed, method, url)

    def _show_response(self, status, reason, headers, body, elapsed,
                        method, url):
        color = ("#4ec9b0" if 200 <= status < 300
                 else "#f48771" if status >= 400
                 else "#ffd700")
        self.status_lbl.config(
            text=f"HTTP {status} {reason}", fg=color)
        self.time_lbl.config(text=f"{elapsed*1000:.0f}ms")

        self._response_text.delete("1.0", tk.END)
        self._response_text.insert("1.0", body)

        hdr_str = "\n".join(f"{k}: {v}" for k, v in headers.items())
        self._res_header_text.delete("1.0", tk.END)
        self._res_header_text.insert("1.0", hdr_str)

        # 履歴に追加
        entry = f"[{datetime.now().strftime('%H:%M:%S')}] {method} {status} {url}"
        self._history.insert(0, (entry, method, url))
        self.history_list.insert(0, entry)

    def _load_history(self, event=None):
        sel = self.history_list.curselection()
        if not sel:
            return
        _, method, url = self._history[sel[0]]
        self.method_var.set(method)
        self.url_var.set(url)


if __name__ == "__main__":
    root = tk.Tk()
    app = App064(root)
    root.mainloop()

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app064.py と保存します。

  2. 2
    クラスの骨格を作る

    App064クラスを定義し、__init__とmainloop()の最小構成を作ります。

  3. 3
    タイトルバーを作る

    Frameを使ってカラーバー付きのタイトルエリアを作ります。

  4. 4
    入力フォームを実装する

    LabelFrameとEntryウィジェットで入力エリアを作ります。

  5. 5
    処理ロジックを実装する

    _execute()メソッドにメインロジックを実装します。

  6. 6
    結果表示を実装する

    TextウィジェットかLabelに結果を表示する_show_result()を実装します。

  7. 7
    エラー処理を追加する

    try-exceptとmessageboxでエラーハンドリングを追加します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 データの保存機能

処理結果をCSV・TXTファイルに保存する機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 日本語フォントが表示されない

原因:システムに日本語フォントが見つからない場合があります。

解決法:font引数を省略するかシステムに合ったフォントを指定してください。

❌ ライブラリのインポートエラー

原因:必要なライブラリがインストールされていません。

解決法:pip install コマンドで必要なライブラリをインストールしてください。 (pip install requests)

❌ ウィンドウサイズが合わない

原因:画面解像度や表示スケールによって異なる場合があります。

解決法:root.geometry()で適切なサイズに調整してください。

9. 練習問題

アプリの理解を深めるための練習問題です。

  1. 課題1:機能拡張

    APIテストクライアントに新しい機能を1つ追加してみましょう。

  2. 課題2:UIの改善

    色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしましょう。

  3. 課題3:保存機能の追加

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。