Technology

複数のWeb APIを共通の関数インターフェイスにまとめる方法-アダプタパターン入門

Webアプリケーションを書いていると、似た目的のサービスを複数のベンダーから使い分けたい場面が生じます。たとえば、天気情報を取得したいときには、OpenWeatherMap、Open-Meteo、AccuWeatherなど複数のAPIが存在します。これらはリクエストの形も、レスポンスのキー名も、認証方法も違います。

最初は1つのAPIだけを使うつもりでも、後から別のベンダーのサービスを使いたい場合も珍しくありませんが、そのときに各APIの仕様と密結合していると、変更箇所が散らばってしまいます。

この記事では、こうしたWeb APIの呼び出しをアダプタパターンで共通化して、単一の関数の背後に隠す方法を解説します。題材としては、無料で試せる2つの天気API(OpenWeatherMapとOpen-Meteo)を使い、最終的に呼び出し側コードが「どのAPIを使うのか」を意識しなくて済む形を目指します。

前提

本記事は、以下の環境を用いています。

  • Python言語を用いて、httpxライブラリを使います。
  • Python 3.12で動作確認をしています。

※ Pythonを使用しますが、考え方自体は他の言語でも使用できると思います。

目次

事前準備

サンプルコードを手元に動かすには、以下の準備が必要です。

項目内容
Python 3.10以上型注釈の文法を利用するために必要です
httpxのイントールHTTPクライアントとしてhttpxを使用するため、未インストールであれば、環境に応じてuv add httpxまたはpip install httpxで導入します
OpenWeatherMapのアカウントとAPIキーの取得OpenWeatherMapを試す場合のみ必要です。サインアップページ で無料登録し、API キー画面 でキーを取得します。取得したキーは OWM_API_KEY 環境変数に設定してください(例:export OWM_API_KEY=...)。新規キーは有効化までに数十分〜2時間程度かかる場合がありますので、登録は早めに済ませておくと進めやすいです
Open-Meteo は登録不要API キーなしで叩けます。最初に動作確認するなら Open-Meteo から始めると、登録待ちなしで進められます

OpenWeatherMap の登録フローと無料枠の条件は変わる可能性があります。最新は公式の Getting Started を必ず確認してください。OpenWeatherMapについて登録したくない場合のために、Mockを使ったコードも補足に記載しております。

直接APIを叩くと何が困るのか

まず、アダプタなしで2つのAPIを直接叩く場合のサンプルコードを示します。

サンプルコードは、緯度・経度を引数に与えることで、その場所の現在(リアルタイム)の天気データを取得する関数を定義しており、東京の天気情報を取得して標準出力します。

OpenWeatherMapを直接呼ぶ場合:

import os
import httpx

# 緯度・経度を入力して現在の天気情報を取得する
def get_weather_openweather(latitude: float, longitude: float) -> dict:
    api_key = os.environ["OWM_API_KEY"] # 環境変数からAPIキーを取得
    response = httpx.get(
        "https://api.openweathermap.org/data/2.5/weather", # エンドポイント
        params={ # クエリパラメータ
            "lat": latitude, # 緯度
            "lon": longitude, # 経度
            "appid": api_key, # APIキー
            "units": "metric", # 単位に摂氏を指定
        },
        timeout=10.0,
    )
    response.raise_for_status() # 通信エラーのチェック
    data = response.json() # レスポンスを辞書として取得
    return {
        "location": data["name"], # 場所
        "temperature_c": data["main"]["temp"], # 温度(℃)
        "description": data["weather"][0]["description"], # 気象状況
        "wind_kmph": data["wind"]["speed"] * 3.6,  # 風速(単位変換:m/s → km/h)
    }

result = get_weather_openweather(35.6895, 139.6917)
print(result)

ここで使っているresponse.raise_for_status() は、HTTP ステータスコードが 4xx / 5xx のときに例外(httpx.HTTPStatusError)を投げてくれる httpx のメソッドです。これを呼んでおくと、エラーをうっかり成功として進めてしまう事故を防げます。実行結果の例を以下に示します。

{'location': 'Tokyo', 'temperature_c': 23.39, 'description': 'broken clouds', 'wind_kmph': 1.62}

Open-Meteoを直接呼ぶ場合:

import httpx

# 緯度・経度を入力して現在の天気情報を取得する
def get_weather_openmeteo(latitude: float, longitude: float) -> dict:
    response = httpx.get(
        "https://api.open-meteo.com/v1/forecast", # エンドポイント
        params={
            "latitude": latitude, # 緯度
            "longitude": longitude, # 経度
            "current": "temperature_2m,wind_speed_10m,weather_code", # 取得パラメータ
        },
        timeout=10.0,
    )
    response.raise_for_status() 
    data = response.json()
    current = data["current"]
    return {
        "location": f"({latitude}, {longitude})", # 緯度・経度
        "temperature_c": current["temperature_2m"], # 高度2mの温度(℃)
        "description": f"code {current['weather_code']}", # 天気コード(WMO weather interpretation codes)
        "wind_kmph": current["wind_speed_10m"], # 高度10mの風速 (km/h)
    }

result = get_weather_openmeteo(35.6895, 139.6917)
print(result)

実行結果の例を以下に示します。

{'location': '(35.6895, 139.6917)', 'temperature_c': 21.4, 'description': 'code 2', 'wind_kmph': 3.9}

この2つの関数を比較すると、両者に多くの違いが見つかります。

  • エンドポイントが異なる(https://api.openweathermap.org/data/2.5/weatherhttps://api.open-meteo.com/v1/forecast
  • クエリパラメータ名が違う(lat/lon/appid/unitslatitude/longitude/current
  • 認証方式が異なる(OpenWeatherMapはAPIキーを使用、Open-Meteoは認証不要)
  • レスポンス階層が違う(温度の取得がdata["main"]["temp"]current["temperature_2m"]で異なる)
  • 単位が違う(OpenWeatherMapはunits指定をしない場合はK、風速はm/s。Open-Meteoはデフォルトで℃、風速はkm/h)

呼び出し側からこれらの関数を使い分ける場合には、呼び出し側に分岐が漏れてしまいます。

if config.weather_service == "owm":
    weather = get_weather_openweather(35.6895, 139.6917)
elif config.weather_service == "om":
    weather = get_weather_openmeteo(35.6895, 139.6917)
else:
    raise ValueError(f"unknown service: {config.weather_service}")

3つ、4つとサービスが増えてくると、その度に分岐が増えていきます。そして、保守がよくないコードで、このような分岐が別の場所にコピー&ペーストされていれば、それらの手入れも必要になります。さらに、新しいサービスを追加するたびに「呼び出し側の分岐」と「サービスごとの関数」の両方を修正することになり、片方を直し忘れると静かにバグが入ります。

問題の本質は、呼び出し側が「どのサービスか」を知っていることで、サービスごとの違いがコード全体に漏れ出している 点です。アダプタパターンはこれを解きます。

アダプタパターンとは

アダプタパターンは、「形が異なるものを共通の形に変換する「変換層」を、利用側と提供側のあいだに挟む」設計のことです。

今回のケースでは、サービスごとに違う Web API の方言(パラメータ名・レスポンス階層・認証・単位)をこの変換層が吸収し、呼び出し側は共通の形だけを相手にできるようにします。

これから作成するアダプタパターンの全体像を以下に示します。

構成要素の役割は次の通りです。

構成要素役割
共通インターフェイス呼び出し側が触れる部分。入力と出力の形をサービスに依存しない名前で定義する(今回はWeatherInputWeatherOutputクラスを定義)
call関数呼び出し側に公開される唯一の入口。共通インターフェイスで定義された入力を受け取り、共通インターフェイスで定義された出力を返す
レジストリアダプタで変換された情報を辞書形式{サービス識別子:アダプタから得られた情報}としてまとめる。新しいサービスの追加や差し替えを行う
アダプタサービスごとに1つ用意する変換関数。リクエストの組み立て、認証、単位変換、レスポンスの整形などサービスごとに異なる部分をここで処理する

以降の節では、この4要素を順に作成します。まず、「共通インターフェイス」をTypedDictで定義し、次に「アダプタ」をOpenWeatherMapとOpen-Meteo用にそれぞれ実装し、最後に「レジストリ」と「call関数」を組み合わせて呼び出し側コードに公開する、という流れです。

プロジェクト構成

作成するプロジェクトのファイルの配置を示します。

project/
├── interface.py          # 共通インターフェイス(TypedDict)
├── weather.py            # call 関数、レジストリ
├── adapters/
│   ├── __init__.py       # 空でよい
│   ├── openweather.py    # OpenWeatherMap アダプタ
│   └── openmeteo.py      # Open-Meteo アダプタ
└── example.py            # 呼び出し側のサンプル

共通インターフェイスを決める

アダプタパターンでまず行うのは、「裏側がどんなサービスでも、呼び出し側が触れるのはこの形だけ」という共通インターフェイスを決めることです。

今回は、入力を「緯度と経度」、出力を「場所名・気温・天気概要・風速」という簡単な形にそろえます。これを単一の関数callの引数と戻り値として表現します。

# interface.py
from typing import TypedDict

class WeatherInput(TypedDict):
   lat: float # 緯度
   lon: float # 経度

class WeatherOutput(TypedDict):
   location: str        # 場所名
   temperature_c: float # 温度(℃)
   description: str     # 天気の概要
   wind_kmph: float     # 風速(km/h)
# weather.py
from interface import WeatherInput, WeatherOutput

def call(service: str, input: WeatherInput) -> WeatherOutput:
   pass # 後で中身を追加

TypedDict は、開発中において「キーが固定されていて、それに対応した値の型が保証された辞書」をつくるためのクラスです。実行時は普通の dictとして振る舞うので動作には影響しませんが、開発中においてエディタやツールの型チェッカーが、「想定外のキーを使っていないか」や「値の型は一致しているか」を確認してくれます。

ポイントは2つあります。

1つ目は、呼び出し側が各サービスにアクセスするための単一の入口となるcall関数は、サービス名と入力の共通インターフェイス(WeatherInputクラス)を渡し、出力の共通インターフェイス(WeatherOutputクラス)を返すようにしています。これにより、呼び出し側はサービス名と共通インターフェイス以外の知識を持たなくてもよいようにしています。

2つ目は、入出力の形をサービスに依存しないようにしています。WeatherOutputのキーは特定のAPIの命名規則に引きずられていません。たとえば、OpenWeatherMapのmain.tempのような中間層は外に出さず、temperature_cという1階層のフィールドに正規化しています。また、単位もコメントではなく、キー名(_cは℃、_kmphはkm/h)に含める形にしています。

共通インターフェイスを決めた後は、このインターフェイスを満たすために、サービスごとのアダプタ関数を用意します。

アダプタを実装する

アダプタ関数は、共通の入力(WeatherInput)を受け取り、サービス固有のリクエストに変換して投げ、返ってきたレスポンスを共通の出力(WeatherOutput)に変換します。

OpenWeatherMapの場合:

# adapters/openweather.py
import os
import httpx

from interface import WeatherInput, WeatherOutput

def fetch(input: WeatherInput) -> WeatherOutput:
    api_key = os.environ["OWM_API_KEY"]
    response = httpx.get(
        "https://api.openweathermap.org/data/2.5/weather",
        params={
            "lat": input["lat"],
            "lon": input["lon"],
            "appid": api_key,
            "units": "metric",
        },
        timeout=10.0,
    )
    response.raise_for_status()
    data = response.json()
    return {
        "location": data["name"],
        "temperature_c": data["main"]["temp"],
        "description": data["weather"][0]["description"],
        "wind_kmph": data["wind"]["speed"] * 3.6,  
    }

このアダプタでは次の内容を吸収しています。

項目内容
パラメータ名のマッピング共通インターフェイスのlat/lonをOpenWeatherMapのlat/lonに渡しています
認証環境変数から読んだAPIキーをappidパラメータに渡しています。呼び出し側はAPIキーの存在を知りません
単位の正規化units=metric を指定して気温を℃で取得しつつ、風速は m/s で返るため × 3.6 で km/h に換算します
レスポンスの整形ネストされている data["main"]["temp"]data["weather"][0]["description"] を平坦な WeatherOutput のキーに詰め直します

Open-Meteoの場合:

Open-Meteoは天気概要を文字列ではなくWMO天気コード(数値)で返します。共通インターフェイス側は文字列のdescriptionを求めているため、アダプタ側で対応表を作ってテキストに変換します。

# adapters/openmeteo.py
import httpx

from interface import WeatherInput, WeatherOutput

# 天気コード(WMO weather interpretation codes)と天気概要の対応表
_WMO_CODE_TO_TEXT = {
    0:  "clear sky",
    1:  "mainly clear",
    2:  "partly cloudy",
    3:  "overcast",
    45: "fog",
    51: "light drizzle",
    61: "light rain",
    63: "moderate rain",
    71: "light snow",
    95: "thunderstorm",
}

def fetch(input: WeatherInput) -> WeatherOutput:
    response = httpx.get(
        "https://api.open-meteo.com/v1/forecast",
        params={
            "latitude": input["lat"],
            "longitude": input["lon"],
            "current": "temperature_2m,wind_speed_10m,weather_code",
        },
        timeout=10.0,
    )
    response.raise_for_status()
    data = response.json()
    current = data["current"]
    code = current["weather_code"]
    return {
        "location": f"({input['lat']}, {input['lon']})",
        "temperature_c": current["temperature_2m"],
        "description": _WMO_CODE_TO_TEXT.get(code, f"code {code}"), # 対応表による変換
        "wind_kmph": current["wind_speed_10m"],
    }

このアダプタでは次の内容を吸収しています。

項目内容
パラメータ名のマッピング共通インターフェイスのlat/lonをOpen-Meteoのlatitude/longitudeに渡しています
取得項目の明示Open-Meteoは欲しい変数をcurrentクエリにカンマ区切りで列挙する仕様であるため、アダプタ内で指定します
認証認証不要なので何もしません
単位デフォルトで気温は℃、風速はkm/hで返ってくるため、変換は不要です
天気概要の正規化WMO天気コード(整数)で返ってくるため、対応表で文字列に変換してdescriptionに入れます。対応表にない場合はcode 番号という形で返します。
位置名の補完Open-Meteoはレスポンスに地名を含めないため、緯度・経度を文字列化して与えます。

各アダプタが吸収している内容を整理することで、共通インターフェイスを見直すきっかけにもなります。

呼び出し側の作成

2つのアダプタが揃ったら、サービス名から該当アダプタを引く レジストリ を用意して、call 関数の中で振り分けます。ここでレジストリそのものにも型を付け、各アダプタが共通インターフェイスを満たしていることを静的に検査できるようにします。

# weather.py
from collections.abc import Callable
from adapters import openweather, openmeteo
from interface import WeatherInput, WeatherOutput

# 関数のインターフェイスを定義(型ヒント)
WeatherAdapter = Callable[[WeatherInput], WeatherOutput]

_REGISTRY: dict[str, WeatherAdapter] = {  # レジストリ(辞書)の定義
    "ow": openweather.fetch,
    "om": openmeteo.fetch,
}

def call(service: str, input: WeatherInput) -> WeatherOutput:
    try:
        adapter = _REGISTRY[service]  # サービス名からアダプタを取得
    except KeyError:
        known = ", ".join(sorted(_REGISTRY))
        raise ValueError(f"unknown service: {service!r} (known: {known})")
    return adapter(input)  # アダプタ関数をcallして戻り値をそのまま返す

Callableは、標準ライブラリcollections.abcから提供され、関数のインターフェイスの型ヒントを定義できます。フォーマットは、Callable[[引数の型のリスト], 戻り値の型]という形に決まっており、コードではOpenWeatherMapとOpen-Meteo用のアダプタ関数が 「WeatherInput を1つ引数に取り、WeatherOutput を返す呼び出し可能オブジェクト」 であることを宣言しています。

なお Callabletyping.Callable としても import できますが、Python 3.9 以降は collections.abc.Callable の利用が公式に推奨されています(typing 版は後者の別名として残されているだけです)。

これを _REGISTRY: dict[str, WeatherAdapter] のようにレジストリの型に使うと、後から登録する各アダプタが共通インターフェイスを満たしているかを mypy などの型チェッカーが確認してくれます。たとえばあるアダプタが誤って dict を返していたり、引数の型が違っていたりすると、型チェック時にエラーとして検出できます。

呼び出し側のコードはこう書けるようになります。

# example.py
from weather import call

result = call("ow", {"lat": 35.68, "lon": 139.69})
print(result)

result = call("om", {"lat": 35.68, "lon": 139.69})
print(result)

このコードには、もはや「OpenWeatherMap」や「Open-Meteo」という単語が出てきません。あるのは「サービス識別子」と「共通インターフェイスの入出力」だけです。サービスごとの差異は完全にアダプタ層に閉じ込められました。

example.pyを実行すると、次のような結果が得られます。

{'location': 'Tokyo', 'temperature_c': 17.48, 'description': 'overcast clouds', 'wind_kmph': 1.62}
{'location': '(35.68, 139.69)', 'temperature_c': 16.9, 'description': 'partly cloudy', 'wind_kmph': 1.6}

差し替えと追加

アダプタパターンを使用したことのメリットの1つとして、設定値だけを書き換えれば、呼び出し側を一切触らずに使うサービスを切り替えられます。

# example.py
import os

from weather import call

service = os.environ.get("WEATHER_SERVICE", "om")
result = call(service, {"lat": 35.68, "lon": 139.69})

OpenWeatherMap の無料枠を使い切ったので一時的に Open-Meteo に逃げる、テスト環境ではキー不要の Open-Meteo を使う、といった運用判断が呼び出し側に影響しなくなります。

また、新しいサービスを追加したくなった場合は、アダプタ(adapters/mock.pyにfetch(input) -> WeatherOutput)を実装して、レジストリに追加します。

# weather.py (レジストリへの追加部分のみ抽出)
from adapters import openweather, openmeteo, mock

_REGISTRY: dict[str, WeatherAdapter] = {
    "ow":   openweather.fetch,
    "om":   openmeteo.fetch,
    "mock": mock.fetch,
}

これで call("mock", ...) が動きます。呼び出し側のコードには一切手が入りません。あちこちの if/elif を増やしたり、複数のファイルを横断して同じ修正を入れたりする必要もありません。

新しい要件、たとえば「降水確率も返してほしい」が出てきたときは、WeatherOutput の項目に「降水確率」を加え、各アダプタでその値を埋める修正だけで済みます。共通インターフェイスを拡張する変更は依然として複数のアダプタに及びますが、変更箇所は 「インターフェイスと各アダプタ」 であり、呼び出し側コードはそのままです。

補足:OpenWeatherMapがなくてもハンズオンを進める方法

OpenWeatherMap の API キーが手元に無い段階でも、adapters/mock.py を2つ目のアダプタとして書けば、「サービスの切り替え」までは登録なしで動かせます。

# adapters/mock.py
from interface import WeatherInput, WeatherOutput

def fetch(input: WeatherInput) -> WeatherOutput:
    return {
        "location": "mock",
        "temperature_c": 20.0,
        "description": "stub: always clear",
        "wind_kmph": 5.0,
    }

レジストリに1行加えれば、call("om", ...)call("mock", ...) を切り替えるだけで、呼び出し側コードが無修正のまま異なる「サービス」から同じ形のデータが返ってくることを確認できます。

# weather.py(mock を加えたレジストリ)
from adapters import openmeteo, mock

_REGISTRY: dict[str, WeatherAdapter] = {
    "om":   openmeteo.fetch,
    "mock": mock.fetch,
}

ただし、モックは認証・単位換算・レスポンス階層の正規化といった 「アダプタが現実の差異を吸収する」中身 までは体験させてくれません。記事の意図を完全に味わうには、最終的には OpenWeatherMap も動かしてみることをおすすめします。

まとめ

複数の Web API を扱うアプリケーションでは、サービスごとに異なるリクエスト形式・レスポンス形状・認証方式・単位を呼び出し側に漏らさないことが、保守性を保つうえで重要になります。アダプタパターンはその違いを吸収する変換層を中間に置き、呼び出し側コードをサービスから切り離します。

本記事では次の4要素を順に作成しました。

  • 共通インターフェイス(WeatherInput と WeatherOutput):呼び出し側が触れる契約
  • アダプタ(openweather.py と openmeteo.py):サービスごとに1つ用意する変換関数
  • レジストリ:サービス識別子からアダプタを引く辞書
  • call関数:呼び出し側に公開する単一の入口

この構造により、新しいサービスを追加するときも、既存のサービスを差し替えるときも、変更は「アダプタ1ファイル」と「レジストリの1行」に閉じます。呼び出し側コードはサービスの存在を知らないため、ビジネスロジックが個別APIに汚されません。

一方で、共通インターフェイスを設計する事前コストと、サービス固有機能を捨てるかあるいは別の関数として並列に提供するかの判断が必要になります。サービスが1つしかない段階で先回りすると抽象が空回りするため、「2つ目を入れたくなった」または「将来差し替える可能性が見えている」タイミングで導入するのが目安です。

まずは2つのサービスから始めて、3つ目を追加してみてください。アダプタを1つ書いてレジストリに1行足すだけで動くこと、呼び出し側に一切手が入らないことを実感できると、このパターンの効果が明確になります。

さらに学びたい方へ

タイトルはJavaですが、他言語のエンジニアにも広く読まれています。他のデザインパターンも体系的に学びたい方には、言語問わずこの名著がおすすめです。

参考

-Technology
-, ,