defmodule Membrane.RTSP.Response do
@moduledoc """
This module represents a RTSP response.
"""
@start_line_regex ~r/^RTSP\/(\d\.\d) (\d\d\d) [A-Z a-z]+$/
@line_ending ["\r\n", "\r", "\n"]
@enforce_keys [:status, :version]
defstruct @enforce_keys ++ [headers: [], body: ""]
@type t :: %__MODULE__{
status: non_neg_integer(),
headers: Membrane.RTSP.headers(),
body: ExSDP.t() | binary()
}
@type result :: {:ok, t()} | {:error, atom()}
@spec new(non_neg_integer()) :: t()
def new(status) do
%__MODULE__{version: "1.0", status: status}
end
@doc """
Attaches a header to a RTSP response struct.
```
iex> Response.with_header(Response.new(200), "header_name", "header_value")
%Response{version: "1.0", status: 200, headers: [{"header_name","header_value"}]}
```
"""
@spec with_header(t(), binary(), binary()) :: t()
def with_header(%__MODULE__{headers: headers} = request, name, value)
when is_binary(name) and is_binary(value),
do: %__MODULE__{request | headers: [{name, value} | headers]}
@doc """
Adds a body to the response and sets `Content-Length` header
```
iex> Response.with_body(Response.new(200), "Hello World")
%Response{version: "1.0", status: 200, headers: [{"Content-Length", "11"}], body: "Hello World"}
```
"""
@spec with_body(t(), binary()) :: t()
def with_body(%__MODULE__{} = response, body) do
with_header(%__MODULE__{response | body: body}, "Content-Length", "#{byte_size(body)}")
end
@doc """
Parses RTSP response.
If the body is present it will be parsed according to `Content-Type` header.
Currently only the `application/sdp` is supported.
"""
@spec parse(binary()) :: {:ok, t()} | {:error, :invalid_start_line | :malformed_header}
def parse(response) do
[headers, body] = String.split(response, ["\r\n\r\n", "\n\n", "\r\r"], parts: 2)
with {:ok, {response, headers}} <- parse_start_line(headers),
{:ok, headers} <- parse_headers(headers),
{:ok, body} <- parse_body(body, headers) do
{:ok, %__MODULE__{response | headers: headers, body: body}}
end
end
@doc """
Renders an RTSP response struct into a binary that is a valid
RTSP response string that can be transmitted via communication channel.
```
iex> Response.stringify(%Response{version: "1.0", status: 200, headers: []})
"RTSP/1.0 200 OK\\r\\n\\r\\n"
iex> Response.stringify(%Response{version: "1.0", status: 200, headers: [{"Content-Length", "11"}, {"Session", "15569"}], body: "Hello World"})
"RTSP/1.0 200 OK\\r\\nContent-Length: 11\\r\\nSession: 15569\\r\\n\\r\\nHello World"
```
"""
@spec stringify(t()) :: binary()
def stringify(%__MODULE__{status: status} = response) do
status_line = Enum.join(["RTSP/1.0", "#{status}", render_status(status)], " ")
Enum.join([
status_line,
render_headers(response.headers),
"\r\n\r\n#{response.body}"
])
end
@doc """
Verifies if raw response binary has got proper length by comparing `Content-Length` header value to actual size of body in response.
Returns tuple with verdict, expected size and actual size of body
Example responses:
`{:ok, 512, 512}` - `Content-Length` header value and body size matched. A response is complete.
`{:ok, 0, 0}` - `Content-Length` header missing or set to 0 and no body. A response is complete.
`{:error, 512, 123}` - Missing part of body in response.
`{:error, 512, 0}` - Missing whole body in response.
`{:error, 0, 0}` - Missing part of header or missing delimiter at the and of header part.
"""
@spec verify_content_length(binary()) ::
{:ok, non_neg_integer(), non_neg_integer()}
| {:error, non_neg_integer(), non_neg_integer()}
def verify_content_length(response) do
split_response = String.split(response, ["\r\n\r\n", "\n\n", "\r\r"], parts: 2)
headers = Enum.at(split_response, 0)
body = Enum.at(split_response, 1)
with {:ok, {response, headers}} <- parse_start_line(headers),
{:ok, headers} <- parse_headers(headers),
false <- is_nil(body),
body_size <- byte_size(body),
{:ok, content_legth_str} <-
get_header(%__MODULE__{response | headers: headers}, "Content-Length") do
{content_length, _remainder} = Integer.parse(content_legth_str)
if body_size == content_length do
{:ok, content_length, body_size}
else
{:error, content_length, body_size}
end
else
{:error, :no_such_header} ->
if byte_size(body) == 0 do
{:ok, 0, 0}
else
{:error, 0, 0}
end
_other ->
{:error, 0, 0}
end
end
@doc """
Retrieves the first header matching given name from a response.
```
iex> response = %Response{
...> status: 200,
...> version: "1.0",
...> headers: [{"header_name", "header_value"}]
...> }
iex> Response.get_header(response, "header_name")
{:ok, "header_value"}
iex> Response.get_header(response, "non_existent_header")
{:error, :no_such_header}
```
"""
@spec get_header(t(), binary()) :: {:error, :no_such_header} | {:ok, binary()}
def get_header(%__MODULE__{headers: headers}, name) do
case List.keyfind(headers, name, 0) do
{_name, value} -> {:ok, value}
nil -> {:error, :no_such_header}
end
end
@doc """
Returns true if the response is a success.
```
iex> Response.ok?(Response.new(204))
true
iex> Response.ok?(Response.new(400))
false
```
"""
@spec ok?(t()) :: boolean()
def ok?(%__MODULE__{status: status}) when status >= 200 and status < 300, do: true
def ok?(_response), do: false
@spec parse_start_line(raw_response :: binary()) ::
{:ok, {response :: t(), remainder :: binary}} | {:error, :invalid_start_line}
defp parse_start_line(binary) do
[line, rest] = String.split(binary, @line_ending, parts: 2)
case Regex.run(@start_line_regex, line) do
[_match, version, code] ->
case Integer.parse(code) do
:error ->
{:error, :invalid_status_code}
{code, _rest} when is_number(code) ->
response = %__MODULE__{version: version, status: code}
{:ok, {response, rest}}
end
_other ->
{:error, :invalid_start_line}
end
end
defp parse_headers(headers) do
headers
|> String.split(@line_ending)
|> Bunch.Enum.try_map(fn header ->
case String.split(header, ":", parts: 2) do
[name, " " <> value] -> {:ok, {name, value}}
_else -> {:error, {:malformed_header, header}}
end
end)
end
defp parse_body(data, headers) do
case List.keyfind(headers, "Content-Type", 0) do
{"Content-Type", "application/sdp"} ->
ExSDP.parse(data)
_other ->
{:ok, data}
end
end
defp render_headers([]), do: ""
defp render_headers(list) do
list
|> Enum.map_join("\r\n", &header_to_string/1)
|> String.replace_prefix("", "\r\n")
end
defp header_to_string({header, value}), do: header <> ": " <> value
defp render_status(200), do: "OK"
defp render_status(400), do: "Bad Request"
defp render_status(401), do: "Unauthorized"
defp render_status(403), do: "Forbidden"
defp render_status(404), do: "Not Found"
defp render_status(405), do: "Method Not Allowed"
defp render_status(455), do: "Method Not Valid In This State"
defp render_status(500), do: "Internal Server Error"
defp render_status(501), do: "Not Implemented"
end