Closed thehunmonkgroup closed 6 years ago
Hi, if I am not wrong, curl added accept: */*
to your request. Try to add same header manually to tesla request.
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.
I've already got middleware that adds 'application/json' to all requests:
plug Tesla.Middleware.JSON
Is this not invoked for DELETE requests?
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.
Ok, got it. Is there a way to use middleware for only a subset of HTTP verbs?
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.
Let me clear things here:
Content-Type
only if there is a encodable bodyAccept
headerContent-Type
header is added by httpc adapter (the default)Content-Type
blank value 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.
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.
Please test the actual request using https://requestb.in/ (DebugLogger output is before adapter)
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
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"
}
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?
Bug report filed:
I will add a workaround to httpc adapter
Fixed via 63363f1 in 1.0
branch.
This DELETE request is throwing a content type error:
Bad Content-Type header value: ''
However the same request succeeds in cURL:
The requests look basically the same to me, so I'm not clear why the Tesla one is failing...