SOAP 요청 포맷터

SOAP 엔벨로프를 정렬하고 확인합니다. 자격 증명은 탭에 남습니다.

입력
출력
대기 중문서를 붙여넣으면 검사합니다. 입력하는 동안 검증이 실행됩니다.

모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.

위에 SOAP 요청이나 응답을 붙여넣으면 입력하는 동안 들여쓰기가 됩니다. 이름공간 접두사는 흐리게 표시되어 soap:Body가 표식이 붙은 Body처럼 읽히고, 정형식이 아닌 곳은 모두 줄과 열, 그리고 대신 무엇을 써야 하는지와 함께 보고됩니다. 속성 조절을 3으로 두면 Envelope는 300자짜리 한 줄이기를 그치고, xmlns 선언마다 자기 줄을 갖습니다.

여러분이 붙여넣는 봉투는 거의 틀림없이 로그에서 나온 것입니다. Fiddler의 캡처, PHP SoapClient의 __getLastRequest() 출력, WCF 메시지 추적, SoapUI의 raw 탭, 또는 줄을 접지 않는 로거가 새벽 3시에 써 놓은 한 줄. 그 상태로는 읽을 수 없고, 잘못이 내 쪽인지 상대 쪽인지 말하려면 먼저 읽을 수 있게 만들어야 합니다.

아무것도 업로드되지 않으며, 여기서 그것은 자랑거리가 아니라 핵심입니다. SOAP 페이로드는 비밀번호가 든 WS-Security 토큰, 서명된 SAML 어서션, 계좌번호, 환자 기록을 실어 나릅니다. 이런 검색에서 상위에 오르는 어느 검사기는 여러분의 데이터가 자기 서버에 저장된다는 데 동의하는 체크 상자를 누르게 합니다. SOAP 포매터를 공개한 또 다른 사이트는 제출된 문서를 공개 상태로 저장하고 있고, 구글이 그것을 색인했습니다. 여기서는 파서와 포매터가 모두 이 탭 안의 자바스크립트입니다.

봉투, 그리고 판을 가리키는 URI

SOAP 메시지는 바깥 모양이 정해진 하나의 XML 문서입니다. 루트는 Envelope입니다. Header를 가질 수 있고, 가진다면 Header가 먼저 옵니다. Body는 반드시 있어야 하며, 그 안에 연산의 페이로드나 Fault 중 하나가 들어갑니다. Body 아래는 모두 서비스의 몫입니다.

판을 가리키는 것은 이름공간 URI이고, 접두사가 아닙니다. 접두사는 임의이며 soap, soapenv, SOAP-ENV, env가 모두 쓰입니다. 멀쩡해 보이는 요청에 서버가 VersionMismatch fault를 돌려준다면, 다른 무엇을 보기 전에 URI를 글자 단위로 비교하세요.

  • SOAP 1.1: 이름공간 http://schemas.xmlsoap.org/soap/envelope/ (끝의 슬래시도 일부입니다), Content-Type은 text/xml, 연산은 별도의 SOAPAction 헤더에 두며 그 값은 인용부호로 감싸야 합니다. 빈 인용부호 한 쌍이어도 됩니다.
  • SOAP 1.2: 이름공간 http://www.w3.org/2003/05/soap-envelope, Content-Type은 action 매개변수가 붙은 application/soap+xml, SOAPAction 헤더는 없습니다. 1.2 엔드포인트에 1.1의 content type을 주면 대개 HTTP 415가 돌아와, 실패가 전송 문제처럼 보이게 됩니다.
  • SOAP 1.1은 Body 뒤의 요소를 참아 주었습니다. SOAP 1.2는 그러지 않습니다. Envelope의 자식은 Header와 Body뿐이고, Body가 마지막입니다.

접두사 오류가 가장 흔한 SOAP 고장인 이유

이름공간 선언은 Envelope 요소에 있는데, 여러분이 관심 있는 부분은 그보다 네 단계 아래에 있습니다. 로그에서 흥미로운 조각을 복사하면 접두사는 가져오고 선언은 남겨 둔 셈이 됩니다. 그러면 메시지는 원인이 아니라 접두사 이름을 댑니다. libxml2는 「Namespace prefix soap on Body is not defined」라 하고, .NET은 「'soap' is an undeclared prefix」라 합니다. 여기서의 검사는 더해야 할 선언과 함께 그것을 보고합니다.

반대 실수는 더 조용하고 더 나쁩니다. 접두사 없는 조각을 기본 이름공간 아래에 있는 Body에 붙여넣으면, 그 안의 모든 요소가 그 이름공간으로 옮겨 갑니다. 문서는 파싱되고 서비스는 받아들이며, 필드는 비어서 돌아옵니다. 붙여넣은 조각의 루트에 xmlns=""를 두어 거기서 빠져나오세요.

경험 많은 사람도 걸리는 규칙이 하나 있습니다. 기본 이름공간은 요소 이름에 적용되고, 속성 이름에는 결코 적용되지 않습니다. 그래서 mustUnderstand, actor, role은 봉투의 이름공간이 기본으로 지정되어 있을 때조차 봉투 접두사를 달아야 합니다.

mustUnderstand, 그리고 페이로드를 읽기도 전에 실패하는 헤더

mustUnderstand가 붙은 헤더 블록은 계약입니다. 지목된 역할을 맡은 수신자는 그 블록을 이해하거나, 아니면 메시지 전체를 MustUnderstand fault로 거절하고 다른 것은 아무것도 처리하지 않아야 합니다. 그래서 Body가 완벽히 올바른 요청이 거절당하는 것입니다. 서비스는 Body에 닿지도 못했습니다.

값은 판마다 다르고, 헷갈리면 오류가 아니라 조용한 실패가 됩니다. SOAP 1.1은 문자 「1」이나 「0」을 정하고 기본은 「0」입니다. SOAP 1.2는 xs:boolean으로 형을 주므로 「true」와 「false」도 통합니다. 엄격한 1.1 스택에 mustUnderstand="true"를 보내면 속성이 「없음」으로 읽혀, 필수 헤더가 선택이 되어 버립니다. 나머지 절반은 대상 지정입니다. 1.1은 URI를 쓰는 actor를, 1.2는 그것을 role로 바꾸고 role/none, role/next, role/ultimateReceiver를 정의하며 마지막이 기본값입니다. 이 층의 실패는 대부분, 해당 연산에 보안 정책이 설정되지 않은 서버를 상대로 mustUnderstand가 붙은 WS-Security 헤더를 보낸 경우입니다.

soap:Fault 읽기

Fault는 Body 안의 평범한 요소이고, 있을 때는 Body의 유일한 자식이어야 합니다. 구조는 판 사이에서 완전히 바뀌었으며, 그래서 한쪽 판에 맞춰 쓴 fault 처리가 다른 쪽에서는 조용히 아무것에도 맞지 않습니다.

SOAP 1.1에서 Fault의 자식들은 수식되지 않습니다. faultcode, faultstring, faultactor, detail은 Fault가 봉투 이름공간에 있더라도 이름공간 없이 놓입니다. 그래서 //soap:Fault/soap:faultstring은 아무것도 돌려주지 않고 //soap:Fault/faultstring이어야 합니다. faultcode에는 QName이 들어가며, 보통 soap:Client(여러분의 메시지가 틀렸음)나 soap:Server(상대 쪽이 실패했고, 재시도로 통할 수도 있음)입니다.

SOAP 1.2는 모든 것을 수식하고 이름도 바꿨습니다. Code는 고정 목록(Sender, Receiver, VersionMismatch, MustUnderstand, DataEncodingUnknown)에서 Value를 담고 선택적으로 Subcode를 잇습니다. Reason은 Text 요소를 담으며 각각 xml:lang이 필요합니다. 나머지는 Node, Role, Detail이 대신합니다. 상태 코드도 정보를 나릅니다. 1.1은 어떤 fault든 500을, 1.2는 Sender에 400, Receiver에 500을 돌려줍니다.

코드에서 SOAP 보내고 읽기

여기에 붙여넣는 봉투는 보통 이 가운데 하나에서 나왔습니다. 각 예제는 요청을 보내고, 성공이라 단정하기 전에 Fault를 확인하며, 응답을 안전하게 파싱합니다. 그것은 원격 상대에게서 온 XML이고, 자바·PHP·파이썬의 기본값은 외부 엔티티를 해석하기 때문입니다.

// SOAP 1.1 over fetch. Note SOAPAction: it is a separate header and its
// value must be quoted, even when it is empty.
const envelope = [
  '<?xml version="1.0" encoding="UTF-8"?>',
  '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"',
  '               xmlns:ns="urn:example:orders">',
  '  <soap:Body>',
  '    <ns:GetOrder><ns:id>ORD-4471</ns:id></ns:GetOrder>',
  '  </soap:Body>',
  '</soap:Envelope>',
].join('\n');

const response = await fetch('https://example.com/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'text/xml; charset=utf-8',
    SOAPAction: '"urn:example:orders/GetOrder"',
    // SOAP 1.2 instead: no SOAPAction header, and
    // 'Content-Type': 'application/soap+xml; charset=utf-8; action="urn:example:orders/GetOrder"'
  },
  body: envelope,
});

// A fault arrives with HTTP 500 in SOAP 1.1, so response.ok is false and the
// body still holds the answer. Never throw on the status alone.
const text = await response.text();
const doc = new DOMParser().parseFromString(text, 'application/xml');
const SOAP11 = 'http://schemas.xmlsoap.org/soap/envelope/';
const fault = doc.getElementsByTagNameNS(SOAP11, 'Fault')[0];
if (fault) {
  // faultcode and faultstring are unqualified, even inside a qualified Fault.
  const code = fault.getElementsByTagName('faultcode')[0]?.textContent;
  const reason = fault.getElementsByTagName('faultstring')[0]?.textContent;
  throw new Error(code + ': ' + reason);
}
import requests
from defusedxml.ElementTree import fromstring   # never the stdlib parser here

SOAP11 = 'http://schemas.xmlsoap.org/soap/envelope/'
NS = {'soap': SOAP11, 'ns': 'urn:example:orders'}

envelope = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
               xmlns:ns="urn:example:orders">
  <soap:Body>
    <ns:GetOrder><ns:id>ORD-4471</ns:id></ns:GetOrder>
  </soap:Body>
</soap:Envelope>"""

response = requests.post(
    'https://example.com/orders',
    data=envelope.encode('utf-8'),
    headers={
        'Content-Type': 'text/xml; charset=utf-8',
        'SOAPAction': '"urn:example:orders/GetOrder"',
    },
    timeout=30,
)

# Do not call raise_for_status(): a SOAP 1.1 fault is HTTP 500 and the body
# is the part you need.
root = fromstring(response.content)
fault = root.find('.//soap:Fault', NS)
if fault is not None:
    code = fault.findtext('faultcode')      # unqualified in SOAP 1.1
    reason = fault.findtext('faultstring')
    raise RuntimeError(f'{code}: {reason}')

# For a real client, zeep reads the WSDL and builds the envelope for you.
# This shape is for debugging one call, which is when you end up here.
import jakarta.xml.soap.*;   // javax.xml.soap before Jakarta EE 9
import java.io.ByteArrayOutputStream;

// SOAPConstants.SOAP_1_2_PROTOCOL for a 1.2 endpoint. The choice sets both
// the envelope namespace and the content type, so it is the one line that
// decides which version you are speaking.
MessageFactory factory = MessageFactory.newInstance(SOAPConstants.SOAP_1_1_PROTOCOL);
SOAPMessage message = factory.createMessage();

SOAPEnvelope envelope = message.getSOAPPart().getEnvelope();
envelope.addNamespaceDeclaration("ns", "urn:example:orders");

SOAPBody body = envelope.getBody();
SOAPElement call = body.addChildElement("GetOrder", "ns");
call.addChildElement("id", "ns").addTextNode("ORD-4471");

// SOAPAction, quoted, as a MIME header. SOAP 1.2 does not use it.
message.getMimeHeaders().addHeader("SOAPAction", "\"urn:example:orders/GetOrder\"");
message.saveChanges();

// The raw bytes on the wire: this is what you paste into a formatter.
ByteArrayOutputStream sent = new ByteArrayOutputStream();
message.writeTo(sent);
System.out.println(sent.toString("UTF-8"));

SOAPConnection connection = SOAPConnectionFactory.newInstance().createConnection();
SOAPMessage response = connection.call(message, "https://example.com/orders");

if (response.getSOAPBody().hasFault()) {
    SOAPFault fault = response.getSOAPBody().getFault();
    throw new RuntimeException(
        fault.getFaultCode() + ": " + fault.getFaultString());
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Xml;
using System.Xml.Linq;

const string Soap11 = "http://schemas.xmlsoap.org/soap/envelope/";
XNamespace soap = Soap11;
XNamespace ns = "urn:example:orders";

var envelope = new XDocument(
    new XElement(soap + "Envelope",
        new XAttribute(XNamespace.Xmlns + "soap", Soap11),
        new XElement(soap + "Body",
            new XElement(ns + "GetOrder",
                new XElement(ns + "id", "ORD-4471")))));

using var http = new HttpClient();
var content = new StringContent(envelope.ToString(), Encoding.UTF8);
content.Headers.ContentType = new MediaTypeHeaderValue("text/xml")
{
    CharSet = "utf-8",
};
// SOAP 1.2 instead: media type application/soap+xml with an action parameter,
// and no SOAPAction header.
content.Headers.Add("SOAPAction", "\"urn:example:orders/GetOrder\"");

var response = await http.PostAsync("https://example.com/orders", content);
var body = await response.Content.ReadAsStringAsync();

// A fault is HTTP 500 with a real body, so do not call
// EnsureSuccessStatusCode() before you have looked at it.
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
};
using var reader = XmlReader.Create(new StringReader(body), settings);
var doc = XDocument.Load(reader);

var fault = doc.Descendants(soap + "Fault").FirstOrDefault();
if (fault is not null)
{
    // Unqualified children in SOAP 1.1: no namespace on the element name.
    var code = fault.Element("faultcode")?.Value;
    var reason = fault.Element("faultstring")?.Value;
    throw new InvalidOperationException(code + ": " + reason);
}
<?php
// trace => true is why this snippet exists: it is how you get the raw
// envelope to paste into a formatter and see what was actually sent.
$client = new SoapClient('https://example.com/orders?wsdl', [
    'trace'        => true,
    'exceptions'   => true,
    'soap_version' => SOAP_1_1,   // SOAP_1_2 changes the namespace and the
                                  // content type together
    'cache_wsdl'   => WSDL_CACHE_NONE,
    'stream_context' => stream_context_create([
        'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
    ]),
]);

try {
    $result = $client->GetOrder(['id' => 'ORD-4471']);
} catch (SoapFault $e) {
    // faultcode is the QName from the envelope, e.g. "soap:Client".
    fprintf(STDERR, "%s: %s\n", $e->faultcode, $e->getMessage());
} finally {
    // Both are null unless trace was enabled before the call.
    echo $client->__getLastRequest(), "\n";
    echo $client->__getLastResponse(), "\n";
}
# Capture a request and a response you can actually read. The SOAPAction
# value keeps its own quotes inside the header value.
curl -sS -D headers.txt \
  -H 'Content-Type: text/xml; charset=utf-8' \
  -H 'SOAPAction: "urn:example:orders/GetOrder"' \
  --data-binary @request.xml \
  https://example.com/orders \
  | tee response.xml | xmllint --format --nonet -

# SOAP 1.2: no SOAPAction header, the action rides on the content type.
curl -sS \
  -H 'Content-Type: application/soap+xml; charset=utf-8; action="urn:example:orders/GetOrder"' \
  --data-binary @request.xml \
  https://example.com/orders | xmllint --format --nonet -

# Was it a fault? Binding a namespace to xmllint --xpath is awkward, so match
# on the local name:
xmllint --nonet --xpath 'count(//*[local-name()="Fault"])' response.xml

# curl exits 0 on HTTP 500. Check the status line yourself:
head -1 headers.txt

여섯 가지 모두에서 되풀이되는 실수는 HTTP 500을 전송 실패로 다루는 것입니다. SOAP 1.1 fault는 상태 500과 본문에 담긴 완전한 봉투로 도착하므로, raise_for_status()나 EnsureSuccessStatusCode(), 맨 response.ok 확인은 무엇이 잘못되었는지에 대해 얻을 수 있는 유일한 설명을 버려 버립니다.

자주 묻는 질문

제 봉투에는 비밀번호와 고객 기록이 들어 있습니다. 업로드되나요?

아닙니다. 파서와 포매터는 이 탭의 웹 워커에서 도는 자바스크립트입니다. 무엇을 보낼 엔드포인트도 없고, 편집기에 접근하는 분석 도구도 없고, 제3자 스크립트도 없습니다.

믿지 말고 확인하세요. 네트워크 탭을 열고 봉투를 붙여넣어 정리해 보세요. 페이지는 자기 자원을 한 번 불러온 뒤 조용해집니다. 이 확인은 이 사이트의 어디보다 여기서 중요합니다. WS-Security 헤더는 비밀번호 다이제스트가 든 UsernameToken을, 사내 서비스에서는 적지 않게 비밀번호 자체를 실어 나르기 때문입니다. 입력은 여러분이 지울 때까지 이 브라우저의 localStorage에 남습니다.

SOAP 1.1과 SOAP 1.2의 차이는 무엇인가요?

이름공간 URI에서 시작하세요. 나머지는 모두 거기서 따라 나옵니다. 1.1은 http://schemas.xmlsoap.org/soap/envelope/, 1.2는 http://www.w3.org/2003/05/soap-envelope입니다. 접두사는 아무것도 알려 주지 않습니다.

선 위에서는 1.1이 text/xml과 인용부호가 붙은 별도의 SOAPAction 헤더를 쓰고, 1.2는 action 매개변수가 붙은 application/soap+xml을 쓰며 SOAPAction은 없습니다. 메시지 안에서는 1.2가 Fault를 다시 쓰고 그 모든 부분을 수식했으며, mustUnderstand를 논리형으로 형 지정하고, actor를 role로 바꾸고, Body 뒤의 애플리케이션 요소를 금지했습니다. 운영 중인 서비스 대부분은 아직 1.1입니다.

왜 계속 「선언되지 않은 접두사」 오류가 나나요?

xmlns 선언은 Envelope 요소에 있는데 여러분이 그보다 아래를 복사했기 때문입니다. 접두사는 그것을 묶는 선언이 유효 범위에 있는 동안만 의미가 있으므로, 따로 붙여넣은 soap:Body는 정형식 XML조차 아니고, 적법한 SOAP은 더더욱 아닙니다.

붙여넣은 것의 루트에 묶음을 더하세요. 1.1 조각이라면 xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"입니다. 반대 실수는 오류를 전혀 내지 않으니 살피세요. 기본 이름공간 아래의 Body에 접두사 없는 조각을 붙이면 조용히 그 이름공간으로 옮겨지고, 필드는 비어서 돌아옵니다.

이 도구가 제 봉투를 SOAP 스키마에 비추어 검증하나요?

아닙니다. 그런 척하는 것은 잘못된 종류의 친절입니다. 이 페이지는 봉투를 파싱하고, 정형식 오류를 모두 줄과 열과 함께 보고하고, 각 접두사가 묶여 있는지 확인하고, 데이터를 건드리지 않고 정리하며, 크기와 줄 수와 요소 수를 알려 줍니다.

SOAP의 내용 모델을 강제하지는 않으므로, Header가 Body 뒤에 오거나 Body가 없거나 1.2 봉투 안에 1.1 fault를 만들어 넣어도 따지지 않습니다. 그것들은 스키마 제약입니다. 이름공간 URI에 공개된 SOAP 봉투 스키마에 비추어, 여기 XSD 검사기로 봉투를 검증하세요. WSDL도 읽지 않고, 요청도 보내지 않으며, 서명도 검증하지 않습니다.

여기에 붙여넣을 원본 봉투는 어디서 얻나요?

여러분의 코드가 아니라 클라이언트에서 얻으세요. 라이브러리에 건넨 객체가 아니라 실제로 선에 오른 바이트가 필요하기 때문입니다. PHP라면 trace를 주어 SoapClient를 만들고 __getLastRequest()를 부르세요. 자바의 SAAJ라면 saveChanges() 뒤에 message.writeTo(System.out)을 부르세요. .NET이라면 WCF 메시지 로깅을 켜세요. 파이썬의 zeep이라면 HistoryPlugin을 붙이고 last_sent를 읽으세요.

프로세스 밖에서라면, curl의 --data-binary와 -D로 응답과 헤더를 파일에 쓸 수 있고, Fiddler와 mitmproxy는 살아 있는 트래픽을 잡으며, SoapUI는 양쪽에 raw 탭을 둡니다. 어떻게 얻든 그것은 길고 긴 한 줄로 도착하는데, 이 페이지가 바로 그것을 위한 것입니다.

관련 도구

참고 자료

이 도구로 해결되는 오류