Current section

Files

Jump to
phoenix_gen_api lib phoenix_gen_api structs request.ex
Raw

lib/phoenix_gen_api/structs/request.ex

defmodule PhoenixGenApi.Structs.Request do
@moduledoc """
Request struct for internal using, convert data map from websocket api.
Data from websocket api has payload like this:
```Elixir
%{
"request_id" => "request_id",
"request_type" => "request_type",
"service" => "service",
"user_id" => "user_id",
"device_id" => "device_id",
"args" => %{}
}
```
We need to convert it to struct for internal using.
Like this:
```Elixir
%PhoenixGenApi.Structs.Request{
request_id: "request_id",
request_type: "request_type",
service: "service",
user_id: "user_id",
device_id: "device_id",
args: %{}
}
```
Explain:
- user_id: string, user's id in system.
User's id in system. It need to check permission.
- device_id: string, device id of current connection.
Device id of current connection.
- request_type: string, request type.
Request type. Using for identify function to call in system.
- request_id: string, unique id for request. Make by client.
Unique id for request. Make by client. Using for identify response.
- service: string, service name.
Service name. Using for identify service to call in system.
- args: map, field -> value, arguments for request.
Arguments for request. Using for call function in system.
## Payload Size Validation
`decode!/1` validates the payload size before deserialization to prevent
memory exhaustion from oversized requests. The limit is configurable via
application env:
config :phoenix_gen_api, :request,
max_payload_bytes: 1_000_000 # default: 1MB
Use `max_payload_bytes/0` to read the current configured limit at runtime.
## Security Considerations
- Payload size is checked **before** deserialization to prevent memory
exhaustion attacks where a client sends an enormous map.
- The default limit is **1MB** and can be configured per application needs
via `config :phoenix_gen_api, :request, max_payload_bytes: N`.
- The check uses `:erlang.external_size/1` (when available) for accurate
measurement without allocating the full binary. Falls back to
`byte_size(:erlang.term_to_binary(params))` on older Erlang/OTP releases.
- If the payload exceeds the limit, `decode!/1` raises an error with the
configured maximum and the actual measured size.
"""
alias __MODULE__
alias PhoenixGenApi.Errors.DecodeError
@typedoc "Request struct for internal using, convert data map from websocket api."
@type t :: %__MODULE__{
user_id: String.t() | nil,
device_id: String.t() | nil,
request_type: String.t(),
request_id: String.t(),
service: String.t(),
args: map(),
user_roles: [String.t()] | nil,
version: String.t() | nil
}
@derive Nestru.Decoder
defstruct [
# user's id in system.
:user_id,
# device id of current connection.
:device_id,
# request type.
:request_type,
# unique id for request. Make by client.
:request_id,
# service name.
:service,
# field -> value, arguments for request.
args: %{},
# user roles for permission checking.
user_roles: nil,
# version of the API request.
version: nil
]
@default_max_payload_bytes 1_000_000
@doc """
Returns the configured maximum payload size in bytes.
Reads from `config :phoenix_gen_api, :request, max_payload_bytes`.
Defaults to 1MB (`#{@default_max_payload_bytes}` bytes).
"""
def max_payload_bytes do
Application.get_env(:phoenix_gen_api, :request, [])[:max_payload_bytes] ||
@default_max_payload_bytes
end
@doc """
Create Request from params for convert data map from websocket api.
Validates payload size before deserialization. Raises if the payload
exceeds the configured `max_payload_bytes` limit.
"""
def decode!(params = %{}) do
validate_payload_size!(params)
request =
try do
Nestru.decode!(params, Request)
rescue
e in DecodeError ->
raise e
e ->
raise DecodeError,
code: :invalid_payload,
message: "Malformed request payload: #{Exception.message(e)}",
details: e
end
request = %{request | args: request.args || %{}}
request = %{request | user_roles: validate_user_roles(request.user_roles)}
validate_required_fields!(request)
request
end
defp validate_required_fields!(request) do
missing =
[]
|> then(&if blank?(request.request_type), do: ["request_type" | &1], else: &1)
|> then(&if blank?(request.request_id), do: ["request_id" | &1], else: &1)
|> then(&if blank?(request.service), do: ["service" | &1], else: &1)
if missing != [] do
fields = Enum.reverse(missing) |> Enum.join(", ")
raise DecodeError, code: :missing_field, message: "Missing required fields: #{fields}"
end
end
defp blank?(nil), do: true
defp blank?(""), do: true
defp blank?(_), do: false
defp validate_payload_size!(params) do
max = max_payload_bytes()
actual = payload_size(params)
if actual > max do
msg = "Request payload exceeds maximum size of #{max} bytes (got #{actual} bytes)"
raise DecodeError, code: :invalid_payload, message: msg
end
end
defp payload_size(params) do
if function_exported?(:erlang, :external_size, 1) do
:erlang.external_size(params)
else
params |> :erlang.term_to_binary() |> byte_size()
end
end
defp validate_user_roles(nil), do: nil
defp validate_user_roles(roles) when is_list(roles) do
Enum.filter(roles, &is_binary/1) |> Enum.reject(&(byte_size(&1) == 0))
end
defp validate_user_roles(_), do: nil
end