はじめに
最近SOAP API、REST APIに触れることがあったので勘所をつかむため実際に作ってみる。
SOAP APIとREST APIを作成し、SOAPの場合はREST変換する部分も併せてLambdaで作成した。
認証はCognito使った。APIをホテルにしてるのは適当。インフラは初心者。
- REST API(
/hotels) - SOAP API(
/soap) - SOAP↔REST 変換プロキシ(
/soap-converter) - WSDL 公開エンドポイント(
/wsdl) - Cognito による認証
構成概要
API Gateway
│─ Lambda Authorizer(TOKEN型、TTL=0)
│
├─ GET/POST /hotels
├─ GET/POST /soap
├─ GET/POST /soap-converter
│
└─ GET /wsdl
インフラ構成:
| リソース | 内容 |
|---|---|
| VPC | Public/Private サブネット、NAT Gateway |
| Lambda × 4 | Python 3.12、VPC 内配置 |
| API Gateway | 認可は↓のオーソライザーを設定 |
| Cognito | User Pool + Resource Server + App Client(client_credentials) |
Lambda Authorizer
Cognito client_credentials フローで発行されるトークンには aud クレームが含まれない仕様らしい(ChatGPT談)ので、JWT のペイロードを自前でデコードして検証する Lambda Authorizer を採用。
↓ChatGPT談
なぜ aud が無いのか
OAuth設計的に:
client_credentialsは「誰が使うか(client)」が主体
リソースサーバーの識別は
スコープ
トークンの発行元(iss)
で行う前提
つまりCognitoは
👉 「audでAPIを区別するモデルではない」
# 認証ハンドラ
def authorizer_handler(event, context):
token_string = event.get('authorizationToken', '')
method_arn = event['methodArn']
if token_string.startswith('Bearer '):
token = token_string[7:]
else:
return generate_policy('Deny', method_arn)
parts = token.split('.')
payload = parts[1]
# Base64 パディング補正
padding = 4 - len(payload) % 4
if padding < 4:
payload += '=' * padding
claims = json.loads(base64.urlsafe_b64decode(payload))
# 有効期限チェック
if claims.get('exp', 0) < time.time():
return generate_policy('Deny', method_arn)
# issuer / client_id / token_use / scope 検証
if claims.get('iss') != EXPECTED_ISSUER:
return generate_policy('Deny', method_arn)
if claims.get('client_id') != EXPECTED_CLIENT_ID:
return generate_policy('Deny', method_arn)
if claims.get('token_use') != 'access':
return generate_policy('Deny', method_arn)
token_scopes = claims.get('scope', '').split()
if READ_SCOPE not in token_scopes and WRITE_SCOPE not in token_scopes:
return generate_policy('Deny', method_arn)
return generate_policy('Allow', method_arn, claims.get('client_id'))
キャッシュで古いポリシーが残ることを防ぐため TTL は0設定
CognitoAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Type: TOKEN
AuthorizerResultTtlInSeconds: 0
IdentitySource: method.request.header.Authorization
エンドポイント設計
GET・POST /hotels — REST API
Lambdaに配置。普通のREST APIなので割愛。
GET・POST /soap — SOAP API
def soap_handler(event, context):
http_method = event.get('httpMethod', 'POST')
if http_method == 'GET':
# 返すだけ
soap_response = '''<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:tns="http://hotel.api.example.com/">
<soap:Body>
<tns:GetHotelsResponse>
<hotels>[{"id": "101", "name": "Deluxe Room"}, {"id": "102", "name": "Standard Room"}]</hotels>
</tns:GetHotelsResponse>
</soap:Body>
</soap:Envelope>'''
return {
'statusCode': 200,
'body': soap_response,
'headers': {'Content-Type': 'application/soap+xml'}
}
elif http_method == 'POST':
# SOAP リクエストを処理(ETはxml.etree)
try:
root = ET.fromstring(body)
# SOAP Envelope から Body を抽出
namespaces = {
'soap': 'http://schemas.xmlsoap.org/soap/envelope/',
'tns': 'http://hotel.api.example.com/'
}
body_elem = root.find('.//soap:Body', namespaces)
if body_elem is None:
body_elem = root.find('.//Body')
if body_elem is not None:
# オペレーション判定
get_hotels = body_elem.find('.//{http://hotel.api.example.com/}GetHotels')
book_hotel = body_elem.find('.//{http://hotel.api.example.com/}BookHotel')
if get_hotels is not None:
soap_response = '''<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://hotel.api.example.com/">
<soap:Body>
<tns:GetHotelsResponse>
<hotels>[{"id": "101", "name": "Deluxe Room"}, {"id": "102", "name": "Standard Room"}]</hotels>
</tns:GetHotelsResponse>
</soap:Body>
</soap:Envelope>'''
return {
'statusCode': 200,
'body': soap_response,
'headers': {'Content-Type': 'application/soap+xml'}
}
POST では <tns:GetHotels> / <tns:BookHotel> を XML パースして振り分ける。
GET・POST /soap-converter — SOAP↔REST 変換プロキシ
/soap-converter はREST クライアントが SOAP を意識しなくて済むように HotelSoapFunction を Lambda 間実行 して SOAP(XML) レスポンスを REST(JSON) に変換。例外処理等はここではいろいろ省略している。
今回はシンプルなレスポンスだけど実際はマッピング大変そう。
def converter_handler(event, context):
http_method = event.get('httpMethod', 'GET')
if http_method == 'GET':
# SOAP Lambda を直接実行
soap_response = lambda_client.invoke(
FunctionName='HotelSoapFunction',
InvocationType='RequestResponse',
Payload=json.dumps({'httpMethod': 'GET', 'body': ''})
)
elif http_method == 'POST':
# REST JSON → SOAP XML に変換してから実行
try:
data = json.loads(body) if body else {}
room_id = data.get('roomId', 'N/A')
guest_name = data.get('guestName', 'N/A')
except:
room_id = 'N/A'
guest_name = 'N/A'
soap_request = '<?xml version="1.0" encoding="UTF-8"?>\n<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://hotel.api.example.com/">\n <soap:Body>\n <tns:BookHotel>\n <tns:roomId>' + room_id + '</tns:roomId>\n <tns:guestName>' + guest_name + '</tns:guestName>\n </tns:BookHotel>\n </soap:Body>\n</soap:Envelope>'
soap_response = lambda_client.invoke(
FunctionName='HotelSoapFunction',
InvocationType='RequestResponse',
Payload=json.dumps({'httpMethod': 'POST', 'body': soap_request})
)
# SOAP XML → REST JSON 変換(ETはxml.etree)
response_payload = json.loads(soap_response['Payload'].read().decode('utf-8'))
soap_body = response_payload.get('body', '')
# SOAP レスポンスを REST JSON に変換
if not soap_body:
return {
'statusCode': 500,
'body': json.dumps({'error': 'Empty SOAP response'})
}
try:
root = ET.fromstring(soap_body)
# Namespace を無視して全要素を探す
# GetHotelsResponse を探す
for elem in root.iter():
if elem.tag.endswith('hotels'):
if elem.text:
hotels_data = json.loads(elem.text)
return {
'statusCode': 200,
'body': json.dumps({
'success': True,
'hotels': hotels_data
})
}
elif elem.tag.endswith('result'):
if elem.text:
return {
'statusCode': 200,
'body': json.dumps({
'success': True,
'message': elem.text
})
}
今回はLambda 間実行しているので API Gateway は経由していないが、実際は ECS とかのAPIを使うだろうからその辺の設定は別途必要そう。
GET /wsdl — WSDL を専用エンドポイントで公開
SOAP データとスキーマ定義を混在させないために専用の /wsdl エンドポイントに分離。
実際はAPIカタログとかで公開するのがよい?SOAPよくわからん。
ハマったポイント
1. CloudFormation デプロイ後も古いレスポンスが返る
原因: CloudFormation は ApiDeployment リソースが変わらないと判断するとステージを更新しない。
やったこと: boto3 で description を変更して新しいデプロイを作成する。
import boto3
c = boto3.client('apigateway', region_name='region_name')
c.create_deployment(restApiId='XXXXXX', stageName='dev', description='force-deploy')
2. client_credentials トークンで COGNITO_USER_POOLS 認証が通らない
原因: client_credentials フローのトークンに aud クレームがない → API Gateway が拒否。
やったこと: Lambda Authorizerに切り替えて client_id クレームで検証。
3. Lambda Authorizer のキャッシュで Deny が残る
原因: TTL のデフォルトは 300 秒。ポリシー変更後も古い Deny が返り続ける。
やったこと: AuthorizerResultTtlInSeconds: 0 を設定。
テスト結果
------------------------------------------------------------
=== 認証OK (expect 200) ===
------------------------------------------------------------
> GET https://my.endpoint.amazonaws.com/dev/hotels
< 200 OK
< Content-Type: application/json
< Body: {"success": true, "message": "GET request received", "data": {}}
> POST https://my.endpoint.amazonaws.com/dev/hotels
> Content-Type: application/json
> Body: {"roomId":"101","guestName":"Yamada Taro"}
< 200 OK
< Content-Type: application/json
< Body: {"success": true, "message": "POST request received", "data": {"roomId": "101", "guestName": "Yamada Taro"}}
> GET https://my.endpoint.amazonaws.com/dev/soap
< 200 OK
< Content-Type: application/soap+xml
< Body: <?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://hotel.api.example.com/">
<soap:Body>
<tns:GetHotelsResponse>
<hotels>[{"id": "101", "name": "Deluxe Room"}, {"id": "102", "name": "Standard Room"}]</hotels>
</tns:GetHotelsResponse>
</soap:Body>
</soap:Envelope>
> POST https://my.endpoint.amazonaws.com/dev/soap
> Content-Type: text/xml
> Body: <?xml version="1.0"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://hotel.api.example.com/"><soap:Body><tns:GetHotels/></soap:Body></soap:Envelope>
< 200 OK
< Content-Type: application/soap+xml
< Body: <?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://hotel.api.example.com/">
<soap:Body>
<tns:GetHotelsResponse>
<hotels>[{"id": "101", "name": "Deluxe Room"}, {"id": "102", "name": "Standard Room"}]</hotels>
</tns:GetHotelsResponse>
</soap:Body>
</soap:Envelope>
> GET https://my.endpoint.amazonaws.com/dev/soap-converter
< 200 OK
< Content-Type: application/json
< Body: {"success": true, "hotels": [{"id": "101", "name": "Deluxe Room"}, {"id": "102", "name": "Standard Room"}]}
> POST https://my.endpoint.amazonaws.com/dev/soap-converter
> Content-Type: application/json
> Body: {"roomId":"102","guestName":"Suzuki Hanako"}
< 200 OK
< Content-Type: application/json
< Body: {"success": true, "message": "Booking confirmed for room 102 under guest Suzuki Hanako"}
> GET https://my.endpoint.amazonaws.com/dev/wsdl
< 200 OK
< Content-Type: application/xml
< Body: <?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
xmlns:tns="http://hotel.api.example.com/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://hotel.api.example.com/">
<types>
<xsd:schema targetNamespace="http://hotel.api.example.com/">
<xsd:element name="GetHotels"><xsd:complexType/></xsd:element>
<xsd:element name="GetHotelsResponse">
<xsd:complexType><xsd:sequence>
<xsd:element name="hotels" type="xsd:string"/>
</xsd:sequence></xsd:complexType>
</xsd:element>
<xsd:element name="BookHotel">
<xsd:complexType><xsd:sequence>
<xsd:element name="roomId" type="xsd:string"/>
<xsd:element name="guestName" type="xsd:string"/>
</xsd:sequence></xsd:complexType>
</xsd:element>
<xsd:element name="BookHotelResponse">
<xsd:complexType><xsd:sequence>
<xsd:element name="result" type="xsd:string"/>
</xsd:sequence></xsd:complexType>
</xsd:element>
</xsd:schema>
</types>
<message name="GetHotelsRequest"><part name="parameters" element="tns:GetHotels"/></message>
<message name="GetHotelsResponse"><part name="parameters" element="tns:GetHotelsResponse"/></message>
<message name="BookHotelRequest"><part name="parameters" element="tns:BookHotel"/></message>
<message name="BookHotelResponse"><part name="parameters" element="tns:BookHotelResponse"/></message>
<portType name="HotelServicePortType">
<operation name="GetHotels">
<input message="tns:GetHotelsRequest"/><output message="tns:GetHotelsResponse"/>
</operation>
<operation name="BookHotel">
<input message="tns:BookHotelRequest"/><output message="tns:BookHotelResponse"/>
</operation>
</portType>
<binding name="HotelServiceBinding" type="tns:HotelServicePortType">
<soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
<operation name="GetHotels">
<soap:operation soapAction="GetHotels"/>
<input><soap:body use="literal"/></input>
<output><soap:body use="literal"/></output>
</operation>
<operation name="BookHotel">
<soap:operation soapAction="BookHotel"/>
<input><soap:body use="literal"/></input>
<output><soap:body use="literal"/></output>
</operation>
</binding>
<service name="HotelService">
<documentation>Hotel SOAP Service</documentation>
<port name="HotelServicePort" binding="tns:HotelServiceBinding">
<soap:address location="https://my.endpoint.amazonaws.com/dev/soap"/>
</port>
</service>
</definitions>
------------------------------------------------------------
=== 認証NG (expect 401) ===
------------------------------------------------------------
> GET https://my.endpoint.amazonaws.com/dev/hotels
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}
> POST https://my.endpoint.amazonaws.com/dev/hotels
> Content-Type: application/json
> Body: {"roomId":"101","guestName":"Yamada Taro"}
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}
> GET https://my.endpoint.amazonaws.com/dev/soap
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}
> POST https://my.endpoint.amazonaws.com/dev/soap
> Content-Type: text/xml
> Body: <?xml version="1.0"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:tns="http://hotel.api.example.com/"><soap:Body><tns:GetHotels/></soap:Body></soap:Envelope>
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}
> GET https://my.endpoint.amazonaws.com/dev/soap-converter
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}
> POST https://my.endpoint.amazonaws.com/dev/soap-converter
> Content-Type: application/json
> Body: {"roomId":"102","guestName":"Suzuki Hanako"}
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}
> GET https://my.endpoint.amazonaws.com/dev/wsdl
< 401 OK
< Content-Type: application/json
< Body: {"message": "Unauthorized"}

