26.9 KiB · text 5af55d9
defmodule GitGudWeb.RunnerController do
@moduledoc """
Forgejo Actions runner protocol endpoints.
Mounted at `/api/actions/_apis/runner.v1.<Service>/<Method>`.
Wire format is content-negotiated:
* `application/protobuf` — what real `forgejo-runner` binaries send;
bytes are decoded into the generated `Runner.V1.*` message
structs, responses are encoded the same way.
* `application/json` — Twirp's JSON envelope. Convenient for `curl`
and tests; messages are encoded via `Protobuf.JSON` which emits
the canonical proto3 JSON shape (snake_case keys, base64 bytes,
etc.).
Auth: all methods except Ping require `Authorization: Bearer <token>`.
Register additionally requires the one-shot registration token in the
body, mirroring `forgejo-runner register --token`.
"""
use GitGudWeb, :controller
import GitGudWeb.Plugs.Twirp
alias GitGud.Workflows
alias GitGud.Workflows.Runner, as: RunnerSchema
alias GitGud.Workflows.Runners
alias GitGudWeb.Plugs.CacheRawBody
# Generated protobuf message structs. We can't `alias Runner.V1.*`
# because the Elixir compiler resolves `Runner.` against the
# already-aliased schema module above.
alias :"Elixir.Runner.V1.DeclareRequest", as: DeclareRequest
alias :"Elixir.Runner.V1.DeclareResponse", as: DeclareResponse
alias :"Elixir.Runner.V1.FetchTaskRequest", as: FetchTaskRequest
alias :"Elixir.Runner.V1.FetchTaskResponse", as: FetchTaskResponse
alias :"Elixir.Runner.V1.PingRequest", as: PingRequest
alias :"Elixir.Runner.V1.PingResponse", as: PingResponse
alias :"Elixir.Runner.V1.RegisterRequest", as: RegisterRequest
alias :"Elixir.Runner.V1.RegisterResponse", as: RegisterResponse
alias :"Elixir.Runner.V1.Task", as: TaskMsg
alias :"Elixir.Runner.V1.UpdateLogRequest", as: UpdateLogRequest
alias :"Elixir.Runner.V1.UpdateLogResponse", as: UpdateLogResponse
alias :"Elixir.Runner.V1.UpdateTaskRequest", as: UpdateTaskRequest
alias :"Elixir.Runner.V1.UpdateTaskResponse", as: UpdateTaskResponse
# ── ping ─────────────────────────────────────────────────────────────
def ping(conn, _params) do
with {:ok, %PingRequest{data: data}} <- decode_request(conn, PingRequest) do
# Connect-Go's unary decoder rejects 0-byte responses (see
# FetchTaskResponse + UpdateLogResponse for the same class of
# framing fix). Empty PingRequest.data → empty PingResponse,
# so default to "pong" when the client sent nothing.
reply = if data == "", do: "pong", else: data
respond(conn, %PingResponse{data: reply})
end
end
# ── register ─────────────────────────────────────────────────────────
def register(conn, _params) do
with {:ok, %RegisterRequest{} = req} <- decode_request(conn, RegisterRequest) do
cond do
req.name == "" ->
error(conn, :invalid_argument, "runner name is required")
true ->
case Runners.consume_registration_token(req.token) do
:error ->
error(conn, :unauthenticated, "invalid registration token")
{:ok, scope} ->
attrs = %{
name: req.name,
version: req.version,
labels: %{"labels" => merged_labels(req)}
}
case Runners.register(attrs, scope) do
{:ok, runner, token} ->
respond(conn, %RegisterResponse{runner: render_runner_proto(runner, token)})
{:error, cs} ->
error(conn, :invalid_argument, format_errors(cs))
end
end
end
end
end
defp merged_labels(%RegisterRequest{labels: ls, agent_labels: als}) do
(ls ++ als)
|> Enum.reject(&(&1 == "" or is_nil(&1)))
|> Enum.uniq()
|> case do
[] -> ["ubuntu-latest"]
list -> list
end
end
# ── declare ──────────────────────────────────────────────────────────
def declare(conn, _params) do
with {:ok, %DeclareRequest{} = req} <- decode_request(conn, DeclareRequest),
%RunnerSchema{} = runner <- authenticate(conn) do
# Declare carries the runner's self-reported capabilities. Match
# them to `agent_labels`, NOT `labels`. The operator-assigned
# `labels` field is what `claim_next_job/1` filters on, and we
# don't let the runner self-promote into a job pool it wasn't
# explicitly assigned to.
agent_labels =
case req.labels do
[] -> []
list -> list
end
attrs = %{version: req.version, agent_labels: %{"labels" => agent_labels}}
case runner |> RunnerSchema.changeset(attrs) |> GitGud.Repo.update() do
{:ok, updated} ->
_ = Runners.touch_last_seen(updated)
respond(conn, %DeclareResponse{runner: render_runner_proto(updated, nil)})
{:error, cs} ->
error(conn, :invalid_argument, format_errors(cs))
end
else
nil -> error(conn, :unauthenticated, "bad token")
{:error, reason} -> error(conn, :invalid_argument, inspect(reason))
end
end
# ── fetch_task ───────────────────────────────────────────────────────
def fetch_task(conn, _params) do
with {:ok, %FetchTaskRequest{tasks_version: tv} = req} <-
decode_request(conn, FetchTaskRequest),
%RunnerSchema{} = runner <- authenticate(conn) do
_ = Runners.touch_last_seen(runner)
request_key = first_header(conn, "x-runner-request-key")
# `tasks_version` MUST be non-zero on the wire — proto3 elides
# default values. Bump every reply so a "no work" response is
# never empty bytes (Connect-Go decodes empty unary bodies as
# "method not implemented").
next_version = tv + 1
capacity = max(req.task_capacity || 1, 1)
# Recovery path: if the runner is retrying with the same
# idempotency key, re-return the previously-claimed job.
primary =
case Workflows.recover_claimed_job(runner, request_key) do
%GitGud.Workflows.WorkflowJob{} = j -> j
_ -> Workflows.claim_next_job(runner)
end
case primary do
nil ->
respond(conn, %FetchTaskResponse{
task: nil,
tasks_version: next_version,
additional_tasks: []
})
claimed ->
if request_key, do: Workflows.stamp_request_recovery(runner, request_key, claimed)
# Fill any remaining slots up to the runner's declared
# capacity. Recovery already consumed one slot.
extras =
if capacity > 1 do
Workflows.claim_jobs_for(runner, capacity - 1)
|> Enum.map(&build_task/1)
else
[]
end
respond(conn, %FetchTaskResponse{
task: build_task(claimed),
tasks_version: next_version,
additional_tasks: extras
})
end
else
nil -> error(conn, :unauthenticated, "bad token")
{:error, reason} -> error(conn, :invalid_argument, inspect(reason))
end
end
defp build_task(job) do
run = GitGud.Repo.get!(GitGud.Workflows.WorkflowRun, job.workflow_run_id)
repo =
GitGud.Repo.get!(GitGud.Repositories.Repository, run.repository_id)
|> GitGud.Repo.preload([:owner, :organization])
{secrets, vars} = GitGud.Secrets.resolve_for(repo)
# System-provided built-in vars (e.g. CI_REGISTRY) as defaults;
# user-defined org/repo vars override them.
vars = Map.merge(builtin_vars(), vars)
token = GitGud.Workflows.RunnerJwt.mint(job, run)
%TaskMsg{
id: job.id,
workflow_payload: GitGud.Workflows.RunnerPayload.encode(job),
context: build_context_struct(job, run, repo, token),
secrets: secrets,
# `needs.<upstream_id>` surfaces each declared upstream's
# result + outputs to this job. Empty map when the workflow
# declares no `needs:`.
needs: build_needs(job),
vars: vars
}
end
# Built-in CI variables the forge injects into every job. CI_REGISTRY
# is the bundled OCI registry host (config :git_gud, :registry_host,
# from GITGUD_REGISTRY_HOST) so workflows can `docker login`/push
# without per-repo config. nil host → not injected.
defp builtin_vars do
case Application.get_env(:git_gud, :registry_host) do
host when is_binary(host) and host != "" -> %{"CI_REGISTRY" => host}
_ -> %{}
end
end
# Always send an EMPTY needs map. forgejo-runner's generateWorkflow
# injects a MOCK job (with no `runs-on`) for every Task.needs entry and
# rebuilds the target job's RawNeeds from them; act then can't resolve
# the runs-on-less mock and skips the job via `if: success()`. With no
# entries, no mock is injected and RawNeeds is emptied, so `success()`
# passes and the job runs. Dispatch is already gated on upstream success
# by Workflows.resolve_dependents/1, so the dependency stays honoured.
# Trade-off: `${{ needs.<id>.result/outputs }}` isn't surfaced to the
# job — none of our workflows read cross-job needs outputs.
defp build_needs(_job), do: %{}
# Forgejo's runner proto switched `Task.context` from
# `map<string,string>` to `google.protobuf.Struct` so values can carry
# nested objects, numbers, and bools. Our context is still all
# strings, but the wire format requires Struct{ fields: %{ key =>
# Value{ kind: {:string_value, v} } } } — `kind` is a oneof, so we
# pass the tuple form `{:string_value, v}`.
defp build_context_struct(job, run, repo, token) do
base = GitGudWeb.Endpoint.url()
# Where the runner resolves `uses: actions/checkout@v4` etc.
# Default to code.forgejo.org which curates a mirror of the
# common GitHub Actions. Override via:
# config :git_gud, :default_actions_url, "https://github.com"
# or env GITGUD_DEFAULT_ACTIONS_URL in runtime.exs.
actions_url =
Application.get_env(:git_gud, :default_actions_url, "https://code.forgejo.org")
# GITHUB_REPOSITORY must be the fully-qualified `owner/repo` —
# actions/checkout rejects a bare repo name ("Invalid repository
# 'gitgud'. Expected format {owner}/{repo}."). `repo_handle/1`
# resolves the org or user namespace (needs :owner + :organization
# preloaded, done in build_task/1).
owner = GitGud.Repositories.Storage.repo_handle(repo)
# `github.actor` / `github.triggering_actor`: the pusher's handle when
# attributable, else the repo owner. Without this, `${{ github.actor }}`
# is empty and steps like `docker/login-action` fail with
# "Username required".
actor =
case run.triggered_by_id && GitGud.Repo.get(GitGud.Accounts.User, run.triggered_by_id) do
%GitGud.Accounts.User{handle: h} when is_binary(h) and h != "" -> h
_ -> owner
end
%{
"event" => run.trigger || "push",
"ref" => run.head_ref || "",
"sha" => if(run.head_sha, do: GitGud.Git.to_hex(run.head_sha), else: ""),
"repository" => owner <> "/" <> repo.name,
"repository_owner" => owner,
"actor" => actor,
"triggering_actor" => actor,
"run_number" => maybe_int_to_string(run.number),
"workflow" => run.workflow_name || run.workflow_path,
"job" => job.job_id,
# forgejo-runner reads `token` and uses it as GITHUB_TOKEN for
# checkout and similar actions. `gitea_runtime_token` is the
# equivalent used for artifact + cache API auth.
"token" => token,
"gitea_runtime_token" => token,
# `gitea_default_actions_url` tells the runner where to clone
# `uses: actions/<repo>@<ref>` from. If we point at our own
# instance, the runner expects `actions/checkout` etc. to be
# hosted here — which we don't do. Pointing at code.forgejo.org
# makes references like `actions/checkout@v4` resolve to the
# Forgejo-curated mirror; for GitHub-native references use
# `https://github.com`.
"gitea_default_actions_url" => actions_url,
"ACTIONS_RUNTIME_URL" => base <> "/api/actions/_apis/",
"ACTIONS_RUNTIME_TOKEN" => token,
"ACTIONS_CACHE_URL" => base <> "/api/actions/_apis/artifactcache/"
}
|> wrap_as_struct()
end
defp maybe_int_to_string(n) when is_integer(n), do: Integer.to_string(n)
defp maybe_int_to_string(_), do: ""
defp wrap_as_struct(map) when is_map(map) do
%Google.Protobuf.Struct{
fields:
Map.new(map, fn {k, v} ->
{k, %Google.Protobuf.Value{kind: {:string_value, to_string_or_empty(v)}}}
end)
}
end
defp to_string_or_empty(nil), do: ""
defp to_string_or_empty(v) when is_binary(v), do: v
defp to_string_or_empty(v), do: to_string(v)
# ── fetch_single_task ────────────────────────────────────────────────
# Targeted FetchTask for the runner's recovery / specific-job
# fetch. Same dispatch shape as `fetch_task/2`, but only ever
# returns a single task (no `additional_tasks`) and honors the
# `handle` field if the runner asks for a specific job by id.
def fetch_single_task(conn, _params) do
fetch_single_request = :"Elixir.Runner.V1.FetchSingleTaskRequest"
fetch_single_response = :"Elixir.Runner.V1.FetchSingleTaskResponse"
with {:ok, %{tasks_version: tv} = req} <- decode_request(conn, fetch_single_request),
%RunnerSchema{} = runner <- authenticate(conn) do
_ = Runners.touch_last_seen(runner)
request_key = first_header(conn, "x-runner-request-key")
next_version = tv + 1
job =
case Workflows.recover_claimed_job(runner, request_key) do
%GitGud.Workflows.WorkflowJob{} = j ->
j
_ ->
case Map.get(req, :handle) do
h when is_binary(h) and h != "" -> Workflows.claim_job_by_handle(runner, h)
_ -> Workflows.claim_next_job(runner)
end
end
case job do
nil ->
respond(conn, struct(fetch_single_response, task: nil, tasks_version: next_version))
claimed ->
if request_key, do: Workflows.stamp_request_recovery(runner, request_key, claimed)
respond(
conn,
struct(fetch_single_response, task: build_task(claimed), tasks_version: next_version)
)
end
else
nil -> error(conn, :unauthenticated, "bad token")
{:error, reason} -> error(conn, :invalid_argument, inspect(reason))
end
end
# ── update_task ──────────────────────────────────────────────────────
def update_task(conn, _params) do
with {:ok, %UpdateTaskRequest{state: state} = req} <-
decode_request(conn, UpdateTaskRequest),
%RunnerSchema{} = _runner <- authenticate(conn) do
job_id = (state && state.id) || 0
require Logger
Logger.info(
"update_task incoming state.id=#{inspect(job_id)} " <>
"state.result=#{inspect(state && state.result)} " <>
"outputs=#{inspect(req.outputs |> Map.keys() |> Enum.take(5))}"
)
case GitGud.Repo.get(GitGud.Workflows.WorkflowJob, job_id) do
nil ->
# Ack-and-ignore. Forgejo-runner retries indefinitely on a
# 404, which clobbers logs even when the work is fundamentally
# complete (job long since reaped, db rolled back, etc.).
# We log the miss for forensics but echo back what the runner
# told us so it can stop pestering and free the worker slot.
Logger.warning("update_task lookup miss — acking anyway for state.id=#{inspect(job_id)}")
respond(conn, %UpdateTaskResponse{state: state, sent_outputs: []})
job ->
mapped = map_result(state && state.result)
# Per-step status BEFORE the job-level transition. We get
# one row in state.steps per declared step with its
# individual result + timing; without this, WorkflowStep
# rows stay forever in `pending`.
if state && is_list(state.steps),
do: Workflows.apply_step_states(job, state.steps)
# Persist runner-reported outputs before we transition status —
# downstream `resolve_dependents/1` may pick up dependents
# that read them via `needs.<id>.outputs.<k>`.
sent_outputs =
case req.outputs do
outputs when is_map(outputs) and map_size(outputs) > 0 ->
{:ok, _} = Workflows.set_outputs(job, outputs)
Map.keys(outputs)
_ ->
[]
end
{:ok, updated} = Workflows.update_job_status(job, mapped)
respond(conn, %UpdateTaskResponse{
state: %{state | id: updated.id, result: result_atom(updated.status)},
sent_outputs: sent_outputs
})
end
else
nil -> error(conn, :unauthenticated, "bad token")
{:error, reason} -> error(conn, :invalid_argument, inspect(reason))
end
end
defp map_result(:RESULT_SUCCESS), do: "success"
defp map_result(:RESULT_FAILURE), do: "failure"
defp map_result(:RESULT_CANCELLED), do: "cancelled"
defp map_result(:RESULT_SKIPPED), do: "success"
defp map_result(_), do: "running"
defp result_atom("success"), do: :RESULT_SUCCESS
defp result_atom("failure"), do: :RESULT_FAILURE
defp result_atom("cancelled"), do: :RESULT_CANCELLED
defp result_atom(_), do: :RESULT_UNSPECIFIED
# ── update_log ───────────────────────────────────────────────────────
def update_log(conn, _params) do
with {:ok, %UpdateLogRequest{task_id: tid, index: index, rows: rows}} <-
decode_request(conn, UpdateLogRequest),
%RunnerSchema{} = runner <- authenticate(conn) do
# Forgejo-runner's `task_id` in UpdateLogRequest is *not* the
# WorkflowJob id we sent in FetchTaskResponse — it's a runner-local
# counter (typically 0 for the only-running job). So we ignore
# `tid` for the lookup and use the runner row to find which
# WorkflowJob is `running` for this authenticated runner.
job =
case Workflows.current_job_for_runner(runner) do
%GitGud.Workflows.WorkflowJob{} = j -> j
_ -> nil
end
step = job && Workflows.ensure_log_step(job)
# The runner's `ReportLog` slices its local buffer with
#
# r.logRows = r.logRows[ack-r.logOffset:]
#
# If we lie about `ack_index` (claim more rows than the runner
# sent), the slice runs past `len(r.logRows)` and the runner
# PANICS with `goPanicSliceB`. So `ack_index` MUST be exactly
# `index + length(rows)` — never floor or bump.
#
# The original concern that motivated a floor here — empty
# response bodies confusing Connect-Go / breaking HTTP/1.1
# keepalive framing — is handled by the `Connection: close`
# we set in `respond/2`, so this can be honest.
ack_index = index + length(rows)
cond do
is_nil(step) ->
require Logger
Logger.warning(
"update_log: no running job for runner_id=#{inspect(runner.id)} task_id=#{inspect(tid)}; acking"
)
respond(conn, %UpdateLogResponse{ack_index: ack_index})
true ->
:ok = Workflows.append_log(step, index, rows)
respond(conn, %UpdateLogResponse{ack_index: ack_index})
end
else
nil -> error(conn, :unauthenticated, "bad token")
{:error, reason} -> error(conn, :invalid_argument, inspect(reason))
end
end
# ── codec ────────────────────────────────────────────────────────────
# Recognized protobuf content types. Newer Forgejo runners (Connect-Go)
# send `application/proto`; older Twirp clients send
# `application/protobuf`. The response must echo the *same* string the
# client used — Connect-Go validates the response content-type against
# what it asked for, and refuses to interpret `application/protobuf`
# when it expected `application/proto`.
@proto_content_types ["application/proto", "application/protobuf"]
defp wire_format(conn) do
case request_content_type(conn) do
ct when ct in @proto_content_types -> {:protobuf, ct}
_ -> {:json, "application/json"}
end
end
defp request_content_type(conn) do
case Plug.Conn.get_req_header(conn, "content-type") do
[ct | _] -> ct |> String.split(";", parts: 2) |> List.first() |> String.downcase()
_ -> nil
end
end
defp decode_request(conn, msg_module) do
case wire_format(conn) do
{:protobuf, _} ->
body = read_proto_body(conn) |> maybe_decompress(conn)
try do
{:ok, msg_module.decode(body)}
rescue
e -> {:error, {:proto_decode, Exception.message(e)}}
end
{:json, _} ->
params = conn.body_params || %{}
# `Protobuf.JSON.from_decoded/2` already returns `{:ok, msg}` /
# `{:error, _}` — don't double-wrap.
try do
case Protobuf.JSON.from_decoded(params, msg_module) do
{:ok, _msg} = ok -> ok
{:error, e} -> {:error, {:json_decode, inspect(e)}}
msg -> {:ok, msg}
end
rescue
e -> {:error, {:json_decode, Exception.message(e)}}
end
end
end
# Connect-Go's HTTP client may set `content-encoding: gzip` on
# request bodies above a size threshold (especially final-flush
# UpdateLog batches). Plug.Conn doesn't auto-decompress request
# bodies, so we have to do it before handing the bytes to the
# protobuf decoder — otherwise the decoder sees gzip magic
# (`1f 8b ...`) and returns invalid_argument.
#
# Cheap fail-open if zlib raises (e.g., bytes weren't actually
# gzipped despite the header) — pass through the raw body and let
# the protobuf decoder surface the real error.
defp maybe_decompress(body, conn) do
case Plug.Conn.get_req_header(conn, "content-encoding") do
[enc | _] when enc in ["gzip", "x-gzip"] ->
try do
:zlib.gunzip(body)
rescue
_ -> body
end
_ ->
body
end
end
# Plug.Parsers only invokes its body_reader for parsers it knows
# about — for `application/protobuf` it doesn't run, so our
# CacheRawBody assign is empty. Read the body directly when needed.
defp read_proto_body(conn) do
case CacheRawBody.get(conn) do
bin when is_binary(bin) and bin != "" ->
bin
_ ->
case Plug.Conn.read_body(conn, length: 50_000_000) do
{:ok, body, _conn} -> body
{:more, body, _conn} -> body
{:error, _} -> ""
end
end
end
defp respond(conn, %_{} = msg) do
case wire_format(conn) do
{:protobuf, echoed_ct} ->
body =
msg.__struct__.encode(msg)
|> IO.iodata_to_binary()
|> ensure_nonempty_body(msg)
conn
|> put_resp_content_type(echoed_ct, nil)
|> close_runner_conn()
|> send_resp(200, body)
{:json, _} ->
body = Protobuf.JSON.encode!(msg)
conn
|> put_resp_content_type("application/json", nil)
|> close_runner_conn()
|> send_resp(200, body)
end
end
# Connect-Go rejects an empty unary response body. The common case is
# UpdateLogResponse{ack_index: 0} — the runner's first/empty log batch
# — which proto3 serializes to nothing, stalling the runner's log
# handshake and ultimately failing an otherwise-successful job. Encode
# field 1 (ack_index) explicitly as varint 0 so the body is non-empty
# but still decodes to 0. Any other empty message is a forgotten-field
# bug — log loudly and leave it empty.
defp ensure_nonempty_body(<<>>, %UpdateLogResponse{}), do: <<8, 0>>
defp ensure_nonempty_body(<<>>, %_{} = msg) do
require Logger
Logger.error(
"runner-protocol: empty protobuf body for #{inspect(msg.__struct__)}; " <>
"Connect-Go will reject. Populate at least one non-default field."
)
<<>>
end
defp ensure_nonempty_body(body, _msg), do: body
# Disable HTTP/1.1 keep-alive for runner-protocol responses.
#
# Bandit + forgejo-runner's Connect-Go HTTP/1.1 client get into
# framing trouble when our content-length disagrees with bytes-
# on-wire by even one byte: the next request on the same socket
# parses from inside the previous response body, the request line
# looks like garbage, Phoenix returns 404, and the runner reports
# the "unimplemented: 404 Not Found" error. The runner only sends
# one request per logical operation anyway, so the keep-alive
# savings are minimal; force `Connection: close` to make every
# response its own TCP session and the failure class disappears.
defp close_runner_conn(conn) do
Plug.Conn.put_resp_header(conn, "connection", "close")
end
# ── helpers ──────────────────────────────────────────────────────────
defp authenticate(conn) do
# Modern forgejo-runner (v6+ / `runner:12`) sends both
# `x-runner-uuid` and `x-runner-token` on every Twirp call (see
# forgejo/runner internal/pkg/client/header.go). When the UUID is
# present we prefer it — single-row lookup on the unique uuid
# index, plus a constant-time identity check.
uuid = first_header(conn, "x-runner-uuid")
token = first_header(conn, "x-runner-token") || bearer_token(conn)
cond do
uuid && token ->
case Runners.find_by_uuid_and_token(uuid, token) do
%RunnerSchema{} = runner -> runner
_ -> nil
end
token ->
case Runners.find_by_token(token) do
%RunnerSchema{} = runner -> runner
_ -> nil
end
true ->
nil
end
end
defp first_header(conn, name) do
case Plug.Conn.get_req_header(conn, name) do
[v | _] when v != "" -> v
_ -> nil
end
end
defp bearer_token(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> t] when t != "" -> t
_ -> nil
end
end
defp render_runner_proto(%RunnerSchema{} = r, token) do
struct(
:"Elixir.Runner.V1.Runner",
id: r.id,
uuid: r.uuid,
name: r.name,
version: r.version || "",
labels: List.wrap(r.labels["labels"] || []),
token: token || "",
address: ""
)
end
defp format_errors(%Ecto.Changeset{} = cs) do
cs
|> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
|> Jason.encode!()
end
end