サーバーがIIS(Windows)だと起きやすいのかな

某有料サービス(FAX扱うやつ)のAPI呼び出しを、Spring BootのRestTemplateを使って実現するアプリケーションの開発をしているところ、レスポンスにUTF-8 BOMを付けてくる行儀の悪いAPIに出会い、半日を潰してしまいました。

curlでファイルに書き出してみると、JSONが始まる前にBOMらしきバイナリが埋まっています。

エラー内容は次のような感じ。JSONをパースしてオブジェクトに変換するところでBOMに当たってエラーになっています。

org.springframework.web.client.RestClientException: Error while extracting response for type [class xxxxxx] and content type [application/json;charset=utf-8]; nested exception is org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Invalid UTF-8 start byte 0xbb; nested exception is com.fasterxml.jackson.core.JsonParseException: Invalid UTF-8 start byte 0xbb
 at [Source: (PushbackInputStream); line: 1, column: 3]

このAPI、そもそもcurl xxxx | jq .とかやってもBOMのせいでパースできない。有料で外部公開するなら互換性は十分に考慮すべきじゃないでしょうかね。

それはともかく、解決方法がすぐに思いつかず、ChatGPTに色々質問をしてみたけど正解は得られず、RestTemplateをやめて生HttpClientで逃げを打とうかと思っていました。

しかし私もエンジニアのはしくれ。指定されたフレームワークだけを使って仕事を完了させるのがプロというもの。などと悶々とした気持を抱えつつ面倒そうなので気が乗らなかったHttpMessageConverterの実装を試したところ、どうにかうまくいったのでご紹介します。

BOMを無視するConverterの実装

まず、デバッガでエラーの場所を観察してどのConverterなのかを突き止めました。MappingJackson2HttpMessageConverterです。これを継承してカスタマイズしようと思います。

パースする前に正規表現を使い、JSONの文字列が現れる前の部分を削除します。せっかくInputStreamで来てるのに一度Stringにしないといけないのはあんまりかっこよくないしパフォーマンス的にも良くないですが簡単だし今回はこれで我慢します。

ちゃんと実装するとしたらInputStreamのBOMをスキップするような処理が望ましいのかと思います。どこかに偉い人のサンプルコードが落ちてないかな。。

public class CustomMessageConverter extends MappingJackson2HttpMessageConverter {
    private Pattern jsonStartChar = Pattern.compile("^.*?([\\[{]+.*)$");

    @Override
    protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        JavaType javaType = getJavaType(clazz, null);
        return readJavaType(javaType, inputMessage);
    }

    @Override
    public Object read(Type type, Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
        JavaType javaType = getJavaType(type, contextClass);
        return readJavaType(javaType, inputMessage);
    }

    private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
        try {
            if (inputMessage instanceof MappingJacksonInputMessage) {
                Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
                if (deserializationView != null) {
                    return this.objectMapper.readerWithView(deserializationView).forType(javaType).
                        readValue(inputMessage.getBody());
                }
            }
            InputStream bodyIs = inputMessage.getBody();
            String body = IOUtils.toString(bodyIs, StandardCharsets.UTF_8);
            Matcher matcher = jsonStartChar.matcher(body);
            if (matcher.matches()) {
                body = matcher.group(1);
            }
            InputStream newIs = IOUtils.toInputStream(body, StandardCharsets.UTF_8);
            return this.objectMapper.readValue(newIs, javaType);
        } catch (InvalidDefinitionException ex) {
            throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
        } catch (JsonProcessingException ex) {
            throw new HttpMessageNotReadableException("JSON parse error: " + ex.getOriginalMessage(), ex, inputMessage);
        }
    }
}

そして、RestTemplateに設定してやります。

@Configuration
public class MyConfiguration {
    @Bean(name = "restTemplate")
    public RestTemplate defaultRestTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.getMessageConverters().add(0, new CustomMessageConverter());
        return restTemplate;
    }
}

これで、JSONレスポンスの先頭にあるゴミを無視してくれるようになります。

よかったよかった。

ところで、BOMって何か役に立つんですか。デスクトップOSの中だけならともかく、ほんと迷惑なのでインターネットに流さないでもらいたい。

といわけで、また次回。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です