APIでは、requestとresponseのどちらにもContent-Typeが現れます。さらにclientはAcceptで受け取りたい形式を指定できます。この三つを混同すると、JSONを送ったのに415になる、CSVを希望したのにJSONが返る、文字化けした本文を正常データとして保存するといった問題が起こります。
この記事では、media typeを「拡張子の言い換え」ではなく、入力と出力の処理方法を決めるAPI契約として設計します。
Content-TypeとAcceptは向きが違う
RFC 9110では、Content-Typeをmessageに含まれるrepresentationのmedia typeを示すfieldと定義しています。一方、requestのAcceptは、clientがresponseで受け取れるmedia typeの希望を伝えます。
POST /measurements HTTP/1.1
Content-Type: application/json
Accept: application/json
{
"measurement_id": "m-001",
"pressure_pa": 14000000
}
このrequestでは、二つのheaderが別の問いへ答えています。
| header | 対象 | 意味 |
|---|---|---|
| requestのContent-Type | request body | 送信したデータをどう解釈するか |
| requestのAccept | response body | clientが受信できる形式は何か |
| responseのContent-Type | response body | 実際に返したデータをどう解釈するか |
Content-Type: application/jsonだからresponseもJSONになるとは限りません。入力はJSON、出力はCSVというAPIも作れます。requestを解析する処理とresponse形式を選ぶ処理を分離します。
media typeをtype・subtype・parameterで読む
media typeは、基本的にtype/subtypeとparameterで構成されます。
application/json
text/csv; charset=utf-8
application/problem+json
applicationやtextがtype、jsonやcsvがsubtypeです。semicolon以降はparameterで、charsetやboundaryなどを表せます。media type、parameter名、tokenの大文字・小文字を前提にした比較は避け、仕様に沿って正規化します。一方、parameterの値まで無条件に小文字化してはいけません。
同じJSON構文でも、処理上の意味が違えば異なるmedia typeを使う場合があります。たとえばapplication/problem+jsonはProblem Details用の形式です。RFC 6839の+json suffixは、そのrepresentationがJSONの構造規則に従うことを示しますが、application/jsonと同一の業務schemaであることまでは保証しません。
requestの形式をContent-Typeで検証する
JSONだけを受け付けるendpointなら、許可する入力media typeを明示します。
POST /measurements
accepted request media type: application/json
encoding: UTF-8
schema: measurement-create version 1
処理順は次のように分けます。
Content-Typeを検証
↓
byte列をUTF-8としてdecode
↓
JSON構文を解析
↓
field・type・値をschema検証
↓
業務ruleを検証して保存
media typeが未対応なら415 Unsupported Media Typeです。JSONとして壊れていれば構文error、JSONは正しいが必須fieldや値が不正ならvalidation errorとして扱います。RFC 9110の422 Unprocessable Contentは、content typeと構文を理解できても、含まれる指示を処理できない場合に使えます。
| 状態 | 例 | 代表的なresponse |
|---|---|---|
| 未対応media type | application/xmlしか送られていない | 415 |
| JSON構文が不正 | closing braceがない | 400 |
| schema違反 | 必須field欠落、型違い | 422またはAPI規約上の4xx |
| 業務競合 | 既に確定したrecordを変更 | 409 |
status codeだけでなく、エラーbodyで問題の種類と修正可能なfieldを返します。415 responseでは、受け付けるrequest media typeを文書とmachine-readableなAPI定義の両方へ記載します。
JSONの文字コードを推測しない
RFC 8259では、閉じたecosystem以外で交換するJSON textをUTF-8でencodeするよう定めています。また、application/jsonの登録にはrequired parameterもoptional parameterもありません。
API契約では次の形に固定すると明確です。
- JSON bodyはUTF-8で送受信する
- byte列をdecodeできなければJSON解析前に拒否する
- byte order markの許可・拒否を決める
- Unicode文字列と、業務上許可する文字を分けて検証する
- responseのJSONもUTF-8でencodeする
application/json; charset=utf-8を許可する実装はありますが、parameterを付けるかどうかをclientごとの推測に任せません。自分のAPIが受け付ける表記をtestし、契約へ固定します。
Acceptでresponse形式を選ぶ
同じresourceをJSONとCSVで返せるAPIを考えます。
GET /measurements/m-001 HTTP/1.1
Accept: text/csv, application/json;q=0.8
qは相対的な希望度です。省略時は1、0は受け入れ不可を表します。この例ではclientはCSVを優先し、CSVがなければJSONも受け取れます。
Acceptは次のようなmedia rangeを取れます。
application/json:特定のmedia typetext/*:text配下の任意subtype*/*:任意のmedia typeapplication/json;q=0:JSONは受け入れない
より具体的なrangeが優先されます。headerを単純なsubstring検索や先頭一致で処理すると、q=0を選んだり、wildcardの優先順位を誤ったりします。productionではHTTP frameworkのcontent negotiation機能を利用し、同順位時のserver側優先順も決めます。
Acceptがない場合のdefault responseもAPI契約です。通常は最も安定した形式を一つ選び、例ではapplication/jsonとします。clientが明示したどの形式も生成できない場合は406 Not Acceptableを返す設計ができます。
Pythonで入力と出力の判定を分ける
次の例では、headerの構文解析自体はHTTP frameworkが行ったものとし、正規化済みのmedia typeとAccept候補を受け取ります。入力判定と出力選択を別関数にします。
from dataclasses import dataclass
SUPPORTED_INPUT = {"application/json"}
SUPPORTED_OUTPUT = (
"application/json",
"text/csv",
)
class UnsupportedMediaType(ValueError):
pass
class NotAcceptable(ValueError):
pass
@dataclass(frozen=True)
class MediaRange:
value: str
quality: float = 1.0
def validate_request_type(content_type: str | None) -> str:
if not content_type:
raise UnsupportedMediaType("Content-Type is required")
media_type = content_type.split(";", 1)[0].strip().lower()
if media_type not in SUPPORTED_INPUT:
raise UnsupportedMediaType(media_type)
return media_type
def specificity(media_range: str, media_type: str) -> int:
if media_range == media_type:
return 2
if media_range.endswith("/*") and media_type.startswith(
media_range[:-1]
):
return 1
if media_range == "*/*":
return 0
return -1
def choose_response_type(
accepted: list[MediaRange] | None,
) -> str:
if not accepted:
return SUPPORTED_OUTPUT[0]
candidates = []
for server_order, supported in enumerate(SUPPORTED_OUTPUT):
best_match = None
for client_order, item in enumerate(accepted):
media_range = item.value.lower()
match_specificity = specificity(media_range, supported)
if match_specificity < 0:
continue
match_key = (match_specificity, -client_order)
if best_match is None or match_key > best_match[0]:
best_match = (match_key, item)
if best_match is None:
continue
item = best_match[1]
if not 0 <= item.quality <= 1:
raise ValueError("quality must be between 0 and 1")
candidates.append((item.quality, -server_order, supported))
candidates = [item for item in candidates if item[0] > 0]
if candidates:
return max(candidates)[2]
raise NotAcceptable("no supported response media type")
framework層では、UnsupportedMediaTypeを415、NotAcceptableを406へ対応させます。成功時は、選んだ値と同じContent-Typeをresponseへ必ず設定します。
この例はmedia type parameterを含まない、解析済みの単純なrangeを対象としています。raw Accept headerにはparameter、引用符、複数fieldの結合規則があるため、独自のsplit(",") parserをproductionへ持ち込まないことが重要です。
responseのContent-Typeを最後まで一致させる
JSONを生成したのにtext/plainを返したり、CSV errorをJSONへ切り替えたのにtext/csvのまま返したりすると、clientは誤ったparserを選びます。
正常系と異常系の両方で確認します。
- serializerが生成したbyte列と
Content-Typeが一致する - JSON errorは
application/problem+jsonなど契約した形式で返す - CSV responseの文字コード、delimiter、改行、headerを固定する
Content-Encoding: gzipなどの圧縮はmedia typeと別に扱うContent-Dispositionのfilenameはmedia typeの代わりにしない
同じURLから複数形式を返し、cacheを使う場合は、responseがAcceptに依存することをcacheへ伝える設計も必要です。RFC 9110のVaryを使い、少なくともVary: Acceptを検討します。
よくある失敗
- bodyの先頭が
{ならJSONと判断する:media type契約を無視し、content sniffingの差が生まれます。 - 拡張子だけでresponse形式を決める:URL設計とrepresentation選択が密結合になります。
- Acceptを完全一致だけで比べる:wildcard、parameter、qualityを処理できません。
- 何でも
application/jsonとして受ける:JSON構文でも別schemaのmedia typeを誤処理します。 - 415と406を逆にする:415は主にrequest content、406はresponseの選択に関する問題です。
- errorだけ
text/htmlで返す:API clientがJSON parserで失敗し、本来の原因を読めません。
セキュリティと運用
RFC 9110は、受信したtypeを無視して内容を推測するMIME sniffingが、誤判定や権限昇格などの危険につながり得ると説明しています。
- 許可するrequest media typeをendpoint単位でallowlist化する
- body size上限をContent-Type検証とは別に設ける
- parserへ渡す前に文字コードとbyte数を確認する
- 未知のparameterを拒否するか無視するか統一する
- responseには実際のserializerと一致するtypeを設定する
- API keyやtokenをerror bodyや通常logへ含めない
media typeが正しくても内容が安全とは限りません。JSON nesting、CSV formula、XML external entity、file parserの脆弱性などは、形式ごとの検証で防ぎます。
DB・CAD・自動化へ再利用する
Content-TypeとAcceptを明示すると、一つのresourceをJSON API、CSV export、CAD metadata、帳票へ展開できます。ただし、形式ごとに同じfieldの意味、ID、単位、NULL、日時を維持する必要があります。
API仕様には、endpointごとの入力media type、出力候補、default、文字コード、schema version、415・406・422の条件を記録します。client testでは、正常なJSONだけでなく、未指定Content-Type、未知のtype、UTF-8不正、q=0、wildcard、選択不能なAcceptも確認します。
まとめ
Content-TypeとAcceptは、bodyの意味と処理方法を機械へ伝えるデータ契約です。
- requestの
Content-Typeは送信bodyの形式を表す - requestの
Acceptは希望するresponse形式を表す - responseの
Content-Typeは実際に返した形式を表す - 未対応request media typeには415を使う
- response候補が選べない場合は406を使える
- JSONはUTF-8へ固定し、decode後に構文とschemaを検証する
q、wildcard、parameterを独自の単純parserで誤処理しない- serializerとresponse headerを必ず一致させる
- 複数representationをcacheする場合は
Vary: Acceptを検討する
この契約をAPI gateway、application、serializer、client testで共有すれば、JSON、CSV、CAD metadataなど複数形式を追加しても、入力と出力の解釈を安定させられます。
