defmodule PhoenixGenApi.Structs.FunConfig do @moduledoc """ For declare a general config for a function. ## Summary Based on function config, the request will be forwarded to the target service. Params will be validated & converted to the correct types before forwarding. The response will be handled based on the response type. For basic check permission, it will check if the user_id of the request is the same as the user_id in the argument (indicated by `check_permission`, ex: check_permission: {:arg, arg_name}). For advanced permission check, please pass the request_info to the target function for checking. ## Example Below is an example of a function config: ```Elixir %FunConfig{ request_type: "get_user", service: :user, nodes: ["user1", "user2"], choose_node_mode: :random, timeout: 1000, mfa: {User, :get_user, []}, arg_types: %{ "user_id" => :string, "device_id" => :string, }, arg_orders: ["user_id", "device_id"], response_type: :async, check_permission: false, request_info: true, } ``` Explain: - `request_type`: the unique identifier for the type of request & response. This is unqiue name in system for client can call right function. - `service`: the service that will handle the request. - `nodes`: the nodes that will handle the request. You can choose local node by set to `:local`. Currently, all nodes must have the same config. - `choose_node_mode`: the way to chose node, support: `:random`, `:hash`, `:round_robin`. - `timeout`: the timeout for the request. - `mfa`: the module, function, and arguments that will be called to handle the request. - `arg_types`: the types of the arguments that will be passed to the function. For validation & converting. - `arg_orders`: the order of the arguments that will be passed to the function. - `response_type`: indicates if the request has a response. Type of response: `:sync`, `:async`, `:stream`, `:none`. - `check_permission`: check permission, false or `{:arg, arg_name}`. - `request_info`: indicates if need request info, info will be added to the request in the last argument. `%{request_id: request_id, user_id: user_id, device_id: device_id}`. `user_id` is the user_id of the user who made the request. """ @type t :: %__MODULE__{ request_type: String.t(), service: atom() | String.t(), nodes: list(String.t()) | {module(), function(), args :: list()}, choose_node_mode: atom() | {atom(), atom()}, timeout: integer() | :infinity, mfa: {module(), function(), args :: list()}, arg_types: map() | nil, arg_orders: list(String.t()), response_type: :sync | :async | :stream | :none, check_permission: false | {:arg, String.t()}, request_info: boolean() } alias __MODULE__ alias PhoenixGenApi.Structs.Request alias PhoenixGenApi.Errors.InvalidType # default max string length for data from client. @default_string_max_length 1000 @default_list_max_items 1000 @default_map_max_items 1000 require Logger defstruct [ # string, an unique identifier for the type of request & response. :request_type, # service. :service, # list, nodes that will handle the request. # for local service run same node set to :local :nodes, # way to chose node, support: :random, :hash, {:hash, hash_key}, :round_robin. :choose_node_mode, # the timeout for the request. :timeout, # the module, function, and arguments that will be called to handle the request. :mfa, # map, field -> type, the types of the arguments that will be passed to the function. For validation & converting. # if nil or empty map, function has no arguments. # Support follow types: # :string - String type. # :boolean - boolean type. # :num - number type (integer or float). # :list_string - list of string. # :list_num - list of number. # :list - mix list, ex: [1, "a", true]. # :map - map type. Not support nested map yet. :arg_types, # list, the order of the arguments that will be passed to the function. :arg_orders, # indicates if the request has a response. Type of response: :sync, :async, :none. # :sync -> the response will call directly & send back to the client. # :async -> Add to queue for other process to handle and send back to the client. # :stream -> stream the response to the client. # :none -> no response needed, using for updating or sending notification. :response_type, # boolean, indicates if need request info, info will be added to the request in the last argument. # %{request_id: request_id, user_id: user_id, device_id: device_id} # user_id is the user_id of the user who made the request. request_info: false, # check permission, false or {:arg, arg_name} # false, ignore basic check permission. # {:arg, arg_name}, verify if request from user has same user_id in argument. # arg type need to be :string. # basic check permission run in gen api service before forward to target service. # if user_id in request_info not match with user_id in argument, return error. # other case, please pass request_info to target function for checking. check_permission: false, # TO-DO: Support more complex permission check. ] @doc """ Select target based on config. """ def get_node(config = %FunConfig{nodes: {m, f, a}}, request = %Request{}) do case apply(m, f, a) do nodes when is_list(nodes) -> config = %{config | nodes: nodes} get_node(config, request) other -> Logger.error("gen_api, get_node, invalid nodes #{inspect other}") raise "invalid nodes #{inspect other}" end end def get_node(config = %FunConfig{}, request = %Request{}) do # TO-DO: Implement sticky node selection. case config.choose_node_mode do :random -> Enum.random(config.nodes) :hash -> hash_node(request, config) {:hash, hash_key} -> hash_node(request, config, hash_key) :round_robin -> round_robin_node(request, config) end end @doc """ Check if the service is local (run on the same node). """ def is_local_service?(config = %FunConfig{}) do config.nodes == :local end @doc """ Validate & Convert request arguments to the correct types. """ def convert_args!(config = %FunConfig{}, request = %Request{}) do validate_args!(config, request) args = request.args || %{} arg_types = config.arg_types || %{} converted_args = Enum.reduce(args, %{}, fn {name, value}, acc -> type = arg_types[name] Map.put(acc, name, convert_arg!(value, type)) end) cond do # function has no arguments. arg_types == nil or map_size(arg_types) == 0 -> [] # function has only one argument. map_size(arg_types) == 1 -> Map.values(converted_args) # function has multiple arguments. true -> result = Enum.reduce(config.arg_orders, [], fn name, acc -> case Map.get(converted_args, name) do nil -> Logger.error("gen_api, request, missing argument #{inspect name} in #{inspect request.request_type}") raise "missing argument #{inspect name} in #{inspect request.request_type}" arg -> acc ++ [arg] end end) Logger.debug("gen_api, request, converted args: #{inspect result}") result end end @doc """ Validate request arguments. """ def validate_args!(%FunConfig{arg_types: nil}, %Request{}) do :ok end def validate_args!(%FunConfig{arg_types: %{}}, %Request{}) do :ok end def validate_args!(config = %FunConfig{}, request = %Request{}) do args = request.args arg_types = config.arg_types if map_size(args) != map_size(arg_types) do Logger.error("gen_api, request, invalid number of arguments for #{inspect request.request_type}, request_id: #{inspect request.request_id}") raise "invalid number of arguments for #{inspect request.request_type}" end config_args = MapSet.new(Map.keys(arg_types)) request_args = MapSet.new(Map.keys(args)) if !MapSet.equal?(config_args, request_args) do Logger.error("gen_api, request, invalid arguments for #{inspect request.request_type}, request_id: #{inspect request.request_id}") raise "invalid arguments for #{inspect request.request_type}" end # Verify argument types & values. Enum.each(args, fn {name, value} -> case arg_types[name] do :list -> if length(value) > @default_list_max_items do Logger.error("gen_api, request, invalid argument size for #{inspect name} in #{inspect request.request_type}, request_id: #{inspect request.request_id}") raise "invalid argument size for #{inspect name} in #{inspect request.request_type}" end arg_list_validation!(value) {:list, max_items} -> if length(value) > max_items do Logger.error("gen_api, request, invalid argument size for #{inspect name} in #{inspect request.request_type}, request_id: #{inspect request.request_id}") raise "invalid argument size for #{inspect name} in #{inspect request.request_type}" end arg_list_validation!(value) :map -> # TO-DO: Implement more for map type validation. if map_size(value) > @default_map_max_items do Logger.error("gen_api, request, invalid argument size for #{inspect name} in #{inspect request.request_type}, request_id: #{inspect request.request_id}") raise "invalid argument size for #{inspect name} in #{inspect request.request_type}" end arg_map_validation!(value) {:map, max_items} -> # TO-DO: Implement more for map type validation. if map_size(value) > max_items do Logger.error("gen_api, request, invalid argument size for #{inspect name} in #{inspect request.request_type}, request_id: #{inspect request.request_id}") raise "invalid argument size for #{inspect name} in #{inspect request.request_type}" end arg_map_validation!(value) other -> arg_validation!(other, value) end end) end defp arg_map_validation!(value) when is_map(value) do Enum.each( value, fn {_key, val} -> cond do is_boolean(val) -> :ok is_float(val) or is_integer(val) -> :ok is_binary(val) -> arg_validation!(:string, val) is_list(val) -> arg_validation!(:list, val) is_map(val) -> Logger.error("gen_api, request, nested map is not supported yet") raise "nested map is not supported yet" end end) end defp arg_list_validation!(value) when is_list(value) do Enum.each( value, fn item-> cond do is_boolean(item) -> :ok is_float(item) or is_integer(item) -> :ok is_binary(item) -> arg_validation!(:string, item) is_map(item) -> Logger.error("gen_api, request, nested map is not supported yet") raise "nested map is not supported yet" is_list(item) -> Logger.error("gen_api, request, nested list is not supported yet") raise "nested list is not supported yet" true -> Logger.error("gen_api, request, unsupported type #{inspect item}") raise "unsupported type #{inspect item}" end end) end defp arg_list_validation!(value, fn_valid?) when is_list(value) do Enum.each( value, fn item-> if fn_valid?.(item) do :ok else Logger.error("gen_api, request, unsupported type #{inspect item} in list") raise "unsupported type of #{inspect item} in list" end end) end defp arg_validation!(_type, nil) do Logger.error("gen_api, request, invalid argument type for nil") raise "invalid argument, not accept nil" end defp arg_validation!(type, value) do case type do nil -> Logger.error("gen_api, request, invalid argument type for #{inspect value}") raise "invalid argument type for #{inspect value}" :boolean -> if value in [true, false] do :ok else Logger.error("gen_api, request, invalid argument type for #{inspect value}") raise "invalid argument type for #{inspect value}" end :num -> if is_float(value) or is_integer(value) do :ok else Logger.error("gen_api, request, invalid argument type for #{inspect value}") raise "invalid argument type for #{inspect value}" end :string -> if String.length(value) > @default_string_max_length do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end {:string, max_length} -> if String.length(value) > max_length do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end :list -> if length(value) > @default_list_max_items do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end arg_list_validation!(value) :list_string -> if length(value) > @default_list_max_items do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end arg_list_validation!(value, fn item -> is_binary(item) and String.length(item) < @default_string_max_length end) {:list_string, max_items, max_item_length} -> if length(value) > max_items do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end arg_list_validation!(value, fn item -> is_binary(item) and String.length(item) < max_item_length end) :list_num -> if length(value) > @default_list_max_items do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end arg_list_validation!(value, fn item -> is_float(item) or is_integer(item) end) {:list_num, max_items} -> if length(value) > max_items do Logger.error("gen_api, request, invalid argument size for #{inspect value}") raise "invalid argument size for #{inspect value}" end arg_list_validation!(value, fn item -> is_float(item) or is_integer(item) end) _ -> Logger.error("gen_api, request, unsupported type #{inspect type} for #{inspect value}") raise "unsupported type #{inspect type} for #{inspect value}" end end ## private functions ## defp hash_node(request, config) do hash_order = :erlang.phash2(request.request_id, length(config.nodes)) Enum.at(config.nodes, hash_order) end # Get node in hash mode with hash_key defp hash_node(request, config, hash_key) do value = Map.get(request.args, hash_key) || Map.get(request, hash_key) case value do nil -> Logger.error("gen_api, hash key #{inspect hash_key} does not existed in request") raise "hash_key #{inspect hash_key} does not existed in request" val -> hash_order = :erlang.phash2(val, length(config.nodes)) Enum.at(config.nodes, hash_order) end end defp round_robin_node(_request, config) do node_num = config.nodes |> length() |> next_round_robin_node_num() Enum.at(config.nodes, node_num) end # Get next node number for round robin mode defp next_round_robin_node_num(1) do 0 end defp next_round_robin_node_num(nodes_length) do case Process.get(:round_robin_num, nil) do nil -> Process.put(:round_robin_num, 0) 0 curr_num -> next_num = curr_num + 1 if next_num < nodes_length do Process.put(:round_robin_num, next_num) next_num else Process.put(:round_robin_num, 0) 0 end end end defp convert_arg!(arg, :string) when is_binary(arg) do arg end defp convert_arg!(arg, :boolean) when arg in [true, false] do arg end defp convert_arg!(arg, :num) when is_float(arg) or is_integer(arg) do arg end defp convert_arg!(arg, :list_string) when is_list(arg) do Enum.each(arg, fn x when is_binary(x) -> x x -> raise InvalidType, x end) arg end defp convert_arg!(arg, :list_num) when is_list(arg) do Enum.each(arg, fn x when is_float(x) or is_integer(x) -> x x -> raise InvalidType, x end) arg end defp convert_arg!(arg, :list) when is_list(arg) do arg end defp convert_arg!(arg, :map) when is_map(arg) do arg end defp convert_arg!(arg, type) do Logger.error("gen_api, request, unsupported type #{inspect type} for #{inspect arg}") raise InvalidType, message: "unsupported type #{inspect type} for #{inspect arg}" end @doc """ Check permission for request from client. """ def check_permission(%Request{}, %FunConfig{check_permission: false}) do true end def check_permission(request = %Request{}, %FunConfig{check_permission: {:arg, arg_name}}) do case Map.get(request.args, arg_name) do nil -> Logger.warning("gen_api, check permission, missing argument #{inspect arg_name} in request: #{inspect request}") false user_id -> user_id == request.user_id end end def check_permission!(request = %Request{}, fun_config = %FunConfig{}) do if not check_permission(request, fun_config) do Logger.warning(" gen_api, check permission, failed, request: #{inspect request}, fun config: #{inspect fun_config}") raise "Permission denied for request from user: #{inspect request.user_id}, request: #{inspect request.request_id}" end end end