elixir-tesla / tesla

The flexible HTTP client library for Elixir, with support for middleware and multiple adapters.
MIT License
2.01k stars 346 forks source link

DELETE request throws content type error #147

Closed thehunmonkgroup closed 6 years ago

thehunmonkgroup commented 6 years ago

This DELETE request is throwing a content type error: Bad Content-Type header value: ''

iex(97)> response = ProfitBricks.delete("/datacenters/#{datacenter_id}/servers/#{server_id}")

20:06:29.305 [debug] > DELETE https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/servers/bc2f5d85-14fe-47c7-a81f-afab9427403c

20:06:29.305 [debug] > Authorization: Basic XXXXX 

20:06:30.076 [debug] 

20:06:30.076 [debug] < HTTP/1.1 400

20:06:30.076 [debug] < connection: close

20:06:30.076 [debug] < content-length: 33

20:06:30.076 [debug] < content-type: text/plain

20:06:30.076 [debug] < date: Mon, 18 Dec 2017 04:06:29 GMT

20:06:30.076 [debug] < server: Apache/2.4.10 (Debian)

20:06:30.076 [debug] < strict-transport-security: max-age=15768000

20:06:30.076 [debug] 

20:06:30.076 [debug] > Bad Content-Type header value: ''
%Tesla.Env{__client__: nil, __module__: ProfitBricks,
 body: "Bad Content-Type header value: ''",
 headers: %{"connection" => "close", "content-length" => "33",
   "content-type" => "text/plain", "date" => "Mon, 18 Dec 2017 04:06:29 GMT",
   "server" => "Apache/2.4.10 (Debian)",
   "strict-transport-security" => "max-age=15768000"}, method: :delete,
 opts: [], query: [], status: 400,
 url: "https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/servers/bc2f5d85-14fe-47c7-a81f-afab9427403c"}

However the same request succeeds in cURL:

curl -v -i -X DELETE \
$  -H "Authorization: Basic XXXXX" \
$  https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/servers/bc2f5d85-14fe-47c7-a81f-afab9427403c
*   Trying 185.48.116.14...
* TCP_NODELAY set
* Connected to api.profitbricks.com (185.48.116.14) port 443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
* Server certificate: *.profitbricks.com
* Server certificate: thawte SSL CA - G2
* Server certificate: thawte Primary Root CA
> DELETE /cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/servers/bc2f5d85-14fe-47c7-a81f-afab9427403c HTTP/1.1
> Host: api.profitbricks.com
> User-Agent: curl/7.54.0
> Accept: */*
> Authorization: Basic XXXXX
> 
< HTTP/1.1 202 Accepted
HTTP/1.1 202 Accepted
< Date: Mon, 18 Dec 2017 04:05:43 GMT
Date: Mon, 18 Dec 2017 04:05:43 GMT
< Server: Apache/2.4.10 (Debian)
Server: Apache/2.4.10 (Debian)
< Location: https://api.profitbricks.com/cloudapi/v4/requests/28cb21e1-0dc0-44af-92da-700e36fe9f79/status
Location: https://api.profitbricks.com/cloudapi/v4/requests/28cb21e1-0dc0-44af-92da-700e36fe9f79/status
< X-RateLimit-Remaining: 49
X-RateLimit-Remaining: 49
< X-RateLimit-Burst: 50
X-RateLimit-Burst: 50
< X-RateLimit-Limit: 120
X-RateLimit-Limit: 120
< ETag: 7df1074bd6f415369eb335bff3ad1781
ETag: 7df1074bd6f415369eb335bff3ad1781
< Content-Length: 0
Content-Length: 0
< Strict-Transport-Security: max-age=15768000
Strict-Transport-Security: max-age=15768000

The requests look basically the same to me, so I'm not clear why the Tesla one is failing...

amatalai commented 6 years ago

Hi, if I am not wrong, curl added accept: */* to your request. Try to add same header manually to tesla request.

teamon commented 6 years ago

As you can see in the debug output, Tesla sends the following headers:

Authorization: Basic XXXXX

and curl:

Host: api.profitbricks.com
User-Agent: curl/7.54.0
Accept: */*
Authorization: Basic XXXXX

But, keep in mind that DebugLogger only shows the headers on the Tesla level - not what is actually send over the wire by adapter. If you debug the request with e.g. requestbin you can see that the headers are actually a bit different - https://requestb.in/pt4qhgpt?inspect

Apart from Cf- headers you can see there is in fact an empty Content-Type: header.

I suppose you use the default httpc adapter for which the Content-Type is required and defaults to "" https://github.com/teamon/tesla/blob/master/lib/tesla/adapter/httpc.ex#L31

Most APIs ignore this empty value and I haven't encounter such issue before. A quick workaround would be to set the Content-Type using Tesla.Middleware.Header to some meaningful value that you API accepts.

thehunmonkgroup commented 6 years ago

I've already got middleware that adds 'application/json' to all requests:

plug Tesla.Middleware.JSON

Is this not invoked for DELETE requests?

amatalai commented 6 years ago

JSON middleware adds content-type: application/json to request if request body is encodable and decodes response if content-type: application/json is present in response headers. It doesn't set accept header.

thehunmonkgroup commented 6 years ago

Ok, got it. Is there a way to use middleware for only a subset of HTTP verbs?

amatalai commented 6 years ago

As far as I know, it's not possible at the moment.

You could create two separate modules with use Tesla, only: [:delete] and use Tesla, except: [:delete] and then defdelegate to them from third module that would be your interface.

teamon commented 6 years ago

Let me clear things here:

It is currently impossible to add middleware only to some verbs, but it is very easy to implement a wrapper:

defmodule JsonOnlyForPost do
  def call(%{method: :post} = env, next, opts), do: Tesla.Middleware.JSON.call(env, next, opts)
  def call(env, next, _opts), do: Tesla.run(env, next)
end

But this will not solve your blank Content-Type issue.

You can however solve it with:

defmodule MyClient do
  use Tesla

  plug Tesla.Middleware.Headers, %{"Content-Type" => "something/valid"}
end

This will add a Content-Type header to all requests. And wether or not it will be overwritten by JSON middleware depends on the order of middlewares.

thehunmonkgroup commented 6 years ago

OK, this is weird. I see the Content-Type header getting passed to the adapter in the debugging, but I'm still getting the same error about Content-Type being empty:

iex(15)> response = ProfitBricks.delete("/datacenters/#{datacenter_id}/lans/3")        

13:28:55.172 [debug] > DELETE https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/lans/3

13:28:55.172 [debug] > Authorization: Basic XXXXX 

13:28:55.172 [debug] > Content-Type: text/plain

13:28:55.922 [debug] 
%Tesla.Env{__client__: nil, __module__: ProfitBricks,
 body: "Bad Content-Type header value: ''",
 headers: %{"connection" => "close", "content-length" => "33",
   "content-type" => "text/plain", "date" => "Mon, 18 Dec 2017 21:28:55 GMT",
   "server" => "Apache/2.4.10 (Debian)",
   "strict-transport-security" => "max-age=15768000"}, method: :delete,
 opts: [], query: [], status: 400,
 url: "https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/lans/3"}

13:28:55.922 [debug] < HTTP/1.1 400

13:28:55.922 [debug] < connection: close

13:28:55.922 [debug] < content-length: 33

13:28:55.922 [debug] < content-type: text/plain

13:28:55.922 [debug] < date: Mon, 18 Dec 2017 21:28:55 GMT

13:28:55.922 [debug] < server: Apache/2.4.10 (Debian)

13:28:55.922 [debug] < strict-transport-security: max-age=15768000

13:28:55.922 [debug] 

13:28:55.922 [debug] > Bad Content-Type header value: ''

So either the adapter is overwriting Content-Type for DELETE requests, or their API endpoint has a bug.

teamon commented 6 years ago

Please test the actual request using https://requestb.in/ (DebugLogger output is before adapter)

thehunmonkgroup commented 6 years ago

My apologies. Here's from https://requestb.in, both the Tesla debug output, and the final requestb.in output:

ProfitBricks.delete("/1ohdn021")

14:38:56.200 [debug] > DELETE https://requestb.in/1ohdn021

14:38:56.200 [debug] > Authorization: Basic XXXXX 

14:38:56.200 [debug] > Content-Type: text/plain

14:38:56.626 [debug] 

14:38:56.626 [debug] < HTTP/1.1 200

14:38:56.626 [debug] < cf-ray: 3cf59d8288d48cff-PDX

14:38:56.626 [debug] < connection: keep-alive

14:38:56.626 [debug] < content-length: 2

14:38:56.626 [debug] < content-type: text/html; charset=utf-8

14:38:56.626 [debug] < date: Mon, 18 Dec 2017 22:38:56 GMT

14:38:56.626 [debug] < server: cloudflare

14:38:56.626 [debug] < set-cookie: __cfduid=d8a8507800d7b20141b455ab6d80fd9521513636736; expires=Tue, 18-Dec-18 22:38:56 GMT; path=/; domain=.requestb.in; HttpOnly

14:38:56.626 [debug] < sponsored-by: https://www.runscope.com

14:38:56.626 [debug] < strict-transport-security: max-age=15552000

14:38:56.626 [debug] < via: 1.1 vegur

14:38:56.626 [debug] < x-content-type-options: nosniff

14:38:56.626 [debug] 

14:38:56.626 [debug] > ok
%Tesla.Env{__client__: nil, __module__: ProfitBricks, body: "ok",
 headers: %{"cf-ray" => "3cf59d8288d48cff-PDX", "connection" => "keep-alive",
   "content-length" => "2", "content-type" => "text/html; charset=utf-8",
   "date" => "Mon, 18 Dec 2017 22:38:56 GMT", "server" => "cloudflare",
   "set-cookie" => "__cfduid=d8a8507800d7b20141b455ab6d80fd9521513636736; expires=Tue, 18-Dec-18 22:38:56 GMT; path=/; domain=.requestb.in; HttpOnly",
   "sponsored-by" => "https://www.runscope.com",
   "strict-transport-security" => "max-age=15552000", "via" => "1.1 vegur",
   "x-content-type-options" => "nosniff"}, method: :delete, opts: [], query: [],
 status: 200, url: "https://requestb.in/1ohdn021"}

https://requestb.in
DELETE /1ohdn021

0 bytes
7s ago
From 73.11.123.137, 172.68.174.39
FORM/POST PARAMETERS
None
HEADERS

Cf-Ray: 3cf59d8288d48cff-PDX

Cf-Ipcountry: US

Content-Type:

Via: 1.1 vegur

Content-Length: 0

Cf-Connecting-Ip: 73.11.123.137

Host: requestb.in

Cf-Visitor: {"scheme":"https"}

Connection: close

Accept-Encoding: gzip

Connect-Time: 1

Total-Route-Time: 0

X-Request-Id: 2cf5cc0a-4560-4ecc-9d4d-c6fc8e82e6b6

Authorization: Basic XXXXX
RAW BODY

None
teamon commented 6 years ago

This is definitely a httpc issue:

For simple GET request there is no Content-Type header

iex(19)> :httpc.request(:get, {'https://httpbin.org/get', []}, [], []) |> elem(1) |> elem(2) |> IO.puts
{
  "args": {},
  "headers": {
    "Connection": "close",
    "Host": "httpbin.org"
  },
  "origin": "89.65.176.175",
  "url": "https://httpbin.org/get"
}

But for DELETE request there is an empty one set.

iex(20)> :httpc.request(:delete, {'https://httpbin.org/delete', []}, [], []) |> elem(1) |> elem(2) |> IO.puts
{
  "args": {},
  "data": "",
  "files": {},
  "form": {},
  "headers": {
    "Connection": "close",
    "Content-Length": "0",
    "Content-Type": "",
    "Host": "httpbin.org"
  },
  "json": null,
  "origin": "89.65.176.175",
  "url": "https://httpbin.org/delete"
}

Even when set explicitly in headers httpc ignores it:

iex(21)> :httpc.request(:delete, {'https://httpbin.org/delete', [{'Content-Type', 'text/plain'}]}, [], []) |> elem(1) |> elem(2) |> IO.puts
{
  "args": {},
  "data": "",
  "files": {},
  "form": {},
  "headers": {
    "Connection": "close",
    "Content-Length": "0",
    "Content-Type": "",
    "Host": "httpbin.org"
  },
  "json": null,
  "origin": "89.65.176.175",
  "url": "https://httpbin.org/delete"
}

As you can see in httpc docs the request can be made either with {url, headers} or {url, headers, content_type, body}.

request() = {url(), headers()} | {url(), headers(), content_type(), body()}

Current httpc adapter implementation in tesla uses the former when body is nil and the latter when body is set. This is because httpc does not accept any body for GET requests:

iex(24)> :httpc.request(:get, {'https://httpbin.org/get', [{'Content-Type', 'text/plain'}], 'my-content-type', ''}, [], []) |> elem(1) |> elem(2) |> IO.puts
** (FunctionClauseError) no function clause matching in :httpc.request/5

    The following arguments were given to :httpc.request/5:

        # 1
        :get

        # 2
        {
      'https://httpbin.org/get',
      [{'Content-Type', 'text/plain'}],
      'my-content-type',
      []
    }

        # 3
        []

        # 4
        []

        # 5
        :default

    (inets) httpc.erl:149: :httpc.request/5

BUT, apparently it does accept empty body for DELETE requests:

iex(24)> :httpc.request(:delete, {'https://httpbin.org/delete', [{'Content-Type', 'text/plain'}], 'my-content-type', ''}, [], []) |> elem(1) |> elem(2) |> IO.puts
{
  "args": {},
  "data": "",
  "files": {},
  "form": {},
  "headers": {
    "Connection": "close",
    "Content-Length": "0",
    "Content-Type": "my-content-type",
    "Host": "httpbin.org"
  },
  "json": null,
  "origin": "89.65.176.175",
  "url": "https://httpbin.org/delete"
}

To looks like something that could be fixed in the adapter but would require proper tests.

For now you can force empty body + Content-Type header to make httpc happy:

defmodule ForceEmptyBodyAndContentTypeForDelete do
  def call(%{method: :delete, body: nil} = env, next, _opts) do
    env = %{env | body: "", headers: Map.put(env.headers, "Content-Type", "text/plain")}
    Tesla.run(env, next)
  end

  def call(env, next, _opts), do: Tesla.run(env, next)
end

defmodule Client do
  use Tesla
  plug ForceEmptyBodyAndContentTypeForDelete
end

Client.delete("https://httpbin.org/delete").body |> IO.puts                                                       {
  "args": {},
  "data": "",
  "files": {},
  "form": {},
  "headers": {
    "Connection": "close",
    "Content-Length": "0",
    "Content-Type": "text/plain", # <--------
    "Host": "httpbin.org"
  },
  "json": null,
  "origin": "x.x.x.x",
  "url": "https://httpbin.org/delete"
}
thehunmonkgroup commented 6 years ago

Profit!

iex(8)> response = ProfitBricks.delete("/datacenters/#{datacenter_id}/lans/#{lan_id}")

10:03:04.191 [debug] > DELETE https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/lans/3

10:03:04.191 [debug] > Authorization: Basic YWRtaW4udmlkZW9Ac3RpcmxhYi5uZXQ6ZWMyZDU3NmI3M2YxNzM3OTFiMjRlNzM5Njc4MTg4MzM= 

10:03:04.191 [debug] > Content-Type: text/plain

10:03:04.191 [debug] 

10:03:04.191 [debug] > 

10:03:05.116 [debug] 

10:03:05.116 [debug] < HTTP/1.1 202

10:03:05.116 [debug] < connection: Keep-Alive

10:03:05.116 [debug] < content-length: 0

10:03:05.116 [debug] < date: Tue, 19 Dec 2017 18:03:04 GMT

10:03:05.116 [debug] < etag: 7df1074bd6f415369eb335bff3ad1781

10:03:05.116 [debug] < keep-alive: timeout=5, max=100

10:03:05.116 [debug] < location: https://api.profitbricks.com/cloudapi/v4/requests/f5072705-0bf5-411c-888d-1a70a4ddece3/status

10:03:05.116 [debug] < server: Apache/2.4.10 (Debian)

10:03:05.116 [debug] < strict-transport-security: max-age=15768000

10:03:05.116 [debug] < x-ratelimit-burst: 50

10:03:05.116 [debug] < x-ratelimit-limit: 120

10:03:05.116 [debug] < x-ratelimit-remaining: 49

10:03:05.116 [debug] 

10:03:05.116 [debug] > 
%Tesla.Env{__client__: nil, __module__: ProfitBricks, body: "",
 headers: %{"connection" => "Keep-Alive", "content-length" => "0",
   "date" => "Tue, 19 Dec 2017 18:03:04 GMT",
   "etag" => "7df1074bd6f415369eb335bff3ad1781",
   "keep-alive" => "timeout=5, max=100",
   "location" => "https://api.profitbricks.com/cloudapi/v4/requests/f5072705-0bf5-411c-888d-1a70a4ddece3/status",
   "server" => "Apache/2.4.10 (Debian)",
   "strict-transport-security" => "max-age=15768000",
   "x-ratelimit-burst" => "50", "x-ratelimit-limit" => "120",
   "x-ratelimit-remaining" => "49"}, method: :delete, opts: [], query: [],
 status: 202,
 url: "https://api.profitbricks.com/cloudapi/v4/datacenters/a919872a-378d-4178-8969-3f23d32ada19/lans/3"}

@teamon thanks so much for your very thorough analysis.

I'll follow up and file a bug report for httpc. I'm assuming the correct fix would be to not pass an empty Content-Type header for request/2, allowing a dev to handle that in request/4 if they need to?

thehunmonkgroup commented 6 years ago

Bug report filed:

https://bugs.erlang.org/browse/ERL-536

teamon commented 6 years ago

I will add a workaround to httpc adapter

teamon commented 6 years ago

Fixed via 63363f1 in 1.0 branch.