import gleam/bit_array
import gleam/bytes_tree
import gleam/dict
import gleam/dynamic.{type Dynamic}
import gleam/erlang/atom
import gleam/erlang/process.{type Subject}
import gleam/http
import gleam/http/request
import gleam/http/response
import gleam/int
import gleam/list
import gleam/option.{None, Some}
import gleam/order
import gleam/otp/actor
import gleam/result
import gleam/string
import http_server_mock/internal/json_codec
import http_server_mock/internal/router
import http_server_mock/types.{
type RecordedRequest, type ResponseDefinition, type Stub, RecordedRequest,
}
import mist
type ServerState {
ServerState(
stubs: List(Stub),
recorded_requests: List(RecordedRequest),
scenarios: dict.Dict(String, String),
)
}
type ServerMessage {
AddStub(stub: Stub, reply_with: Subject(String))
RemoveStub(id: String, reply_with: Subject(Nil))
ClearStubs(reply_with: Subject(Nil))
GetStubs(reply_with: Subject(String))
MatchRequest(
recorded_request: RecordedRequest,
reply_with: Subject(option.Option(ResponseDefinition)),
)
GetRequests(reply_with: Subject(String))
ClearRequests(reply_with: Subject(Nil))
Shutdown
}
type Handle {
Handle(subject: Subject(ServerMessage), supervisor_pid: process.Pid)
}
@target(erlang)
pub fn start_server(port: Int) -> Result(#(Int, Dynamic), String) {
let initial_state =
ServerState(stubs: [], recorded_requests: [], scenarios: dict.new())
use started <- result.try(
actor.new(initial_state)
|> actor.on_message(handle_message)
|> actor.start
|> result.map_error(fn(_) { "Failed to start state actor" }),
)
let subject = started.data
let port_channel = process.new_subject()
let port_selector = process.new_selector() |> process.select(port_channel)
use mist_started <- result.try(
mist.new(fn(mist_request) { handle_http(subject, mist_request) })
|> mist.port(port)
|> mist.bind("0.0.0.0")
|> mist.after_start(fn(actual_port, _scheme, _ip) {
process.send(port_channel, actual_port)
})
|> mist.start
|> result.map_error(fn(_) { "Failed to start HTTP server" }),
)
let actual_port = case process.selector_receive(port_selector, 5000) {
Ok(assigned_port) -> assigned_port
Error(Nil) -> port
}
let server_handle = Handle(subject: subject, supervisor_pid: mist_started.pid)
Ok(#(actual_port, identity(server_handle)))
}
@target(erlang)
pub fn stop_server(handle: Dynamic) -> Nil {
let server_handle: Handle = identity(handle)
process.send(server_handle.subject, Shutdown)
process.send_exit(server_handle.supervisor_pid)
}
@target(erlang)
pub fn add_stub(handle: Dynamic, stub_json: String) -> Result(String, String) {
let server_handle: Handle = identity(handle)
case json_codec.decode_stub(stub_json) {
Error(error_message) -> Error("Invalid stub JSON: " <> error_message)
Ok(stub) -> {
let stub_id =
process.call(server_handle.subject, 5000, fn(reply_subject) {
AddStub(stub, reply_subject)
})
Ok(stub_id)
}
}
}
@target(erlang)
pub fn remove_stub(handle: Dynamic, id: String) -> Nil {
let server_handle: Handle = identity(handle)
process.call(server_handle.subject, 5000, fn(reply_subject) {
RemoveStub(id, reply_subject)
})
}
@target(erlang)
pub fn clear_stubs(handle: Dynamic) -> Nil {
let server_handle: Handle = identity(handle)
process.call(server_handle.subject, 5000, fn(reply_subject) {
ClearStubs(reply_subject)
})
}
@target(erlang)
pub fn get_stubs(handle: Dynamic) -> String {
let server_handle: Handle = identity(handle)
process.call(server_handle.subject, 5000, fn(reply_subject) {
GetStubs(reply_subject)
})
}
@target(erlang)
pub fn get_requests(handle: Dynamic) -> String {
let server_handle: Handle = identity(handle)
process.call(server_handle.subject, 5000, fn(reply_subject) {
GetRequests(reply_subject)
})
}
@target(erlang)
pub fn clear_requests(handle: Dynamic) -> Nil {
let server_handle: Handle = identity(handle)
process.call(server_handle.subject, 5000, fn(reply_subject) {
ClearRequests(reply_subject)
})
}
@target(erlang)
fn handle_message(
state: ServerState,
message: ServerMessage,
) -> actor.Next(ServerState, ServerMessage) {
case message {
Shutdown -> actor.stop()
AddStub(stub, reply_subject) -> {
process.send(reply_subject, stub.id)
actor.continue(
ServerState(..state, stubs: insert_stub(state.stubs, stub)),
)
}
RemoveStub(id, reply_subject) -> {
process.send(reply_subject, Nil)
actor.continue(
ServerState(
..state,
stubs: list.filter(state.stubs, fn(stub) { stub.id != id }),
),
)
}
ClearStubs(reply_subject) -> {
process.send(reply_subject, Nil)
actor.continue(ServerState(..state, stubs: []))
}
GetStubs(reply_subject) -> {
process.send(reply_subject, json_codec.encode_stubs(state.stubs))
actor.continue(state)
}
MatchRequest(recorded_request, reply_subject) -> {
case router.find_match(state.stubs, state.scenarios, recorded_request) {
None -> {
let recorded =
RecordedRequest(..recorded_request, matched_stub_id: None)
process.send(reply_subject, None)
actor.continue(
ServerState(..state, recorded_requests: [
recorded,
..state.recorded_requests
]),
)
}
Some(#(stub, response_def)) -> {
let recorded =
RecordedRequest(..recorded_request, matched_stub_id: Some(stub.id))
let updated_scenarios = advance_scenario(state.scenarios, stub)
process.send(reply_subject, Some(response_def))
actor.continue(
ServerState(
..state,
scenarios: updated_scenarios,
recorded_requests: [recorded, ..state.recorded_requests],
),
)
}
}
}
GetRequests(reply_subject) -> {
process.send(
reply_subject,
json_codec.encode_recorded_requests(state.recorded_requests),
)
actor.continue(state)
}
ClearRequests(reply_subject) -> {
process.send(reply_subject, Nil)
actor.continue(ServerState(..state, recorded_requests: []))
}
}
}
@target(erlang)
fn handle_http(
subject: Subject(ServerMessage),
mist_request: request.Request(mist.Connection),
) -> response.Response(mist.ResponseData) {
case string.starts_with(mist_request.path, "/__admin") {
True -> handle_admin(subject, mist_request)
False -> handle_stub(subject, mist_request)
}
}
@target(erlang)
fn read_body_string(mist_request: request.Request(mist.Connection)) -> String {
case mist.read_body(mist_request, 4_194_304) {
Ok(request_with_body) ->
request_with_body.body
|> bit_array.to_string
|> result.unwrap("")
Error(_) -> ""
}
}
@target(erlang)
fn handle_stub(
subject: Subject(ServerMessage),
mist_request: request.Request(mist.Connection),
) -> response.Response(mist.ResponseData) {
let body_string = read_body_string(mist_request)
let headers_dict =
list.fold(mist_request.headers, dict.new(), fn(header_dict, header_pair) {
let #(key, value) = header_pair
dict.insert(header_dict, string.lowercase(key), value)
})
let recorded_request =
RecordedRequest(
id: new_id(),
method: mist_request.method,
path: mist_request.path,
query: mist_request.query,
headers: headers_dict,
body: body_string,
timestamp_ms: now_ms(),
matched_stub_id: None,
)
case
process.call(subject, 5000, fn(reply_subject) {
MatchRequest(recorded_request, reply_subject)
})
{
None ->
json_response(
"{\"status\":404,\"message\":\"No stub matched\",\"path\":\""
<> mist_request.path
<> "\"}",
404,
)
Some(response_def) -> {
case response_def.delay_ms {
Some(milliseconds) -> sleep(milliseconds)
None -> Nil
}
build_response(response_def)
}
}
}
@target(erlang)
fn handle_admin(
subject: Subject(ServerMessage),
mist_request: request.Request(mist.Connection),
) -> response.Response(mist.ResponseData) {
let body_string = read_body_string(mist_request)
let path = mist_request.path
let method = mist_request.method
case method, path {
http.Get, "/__admin/health" -> json_response("{\"status\":\"ok\"}", 200)
http.Get, "/__admin/stubs" ->
json_response(
process.call(subject, 5000, fn(reply_subject) {
GetStubs(reply_subject)
}),
200,
)
http.Post, "/__admin/stubs" ->
case json_codec.decode_stub(body_string) {
Error(error_message) ->
json_response("{\"error\":\"" <> error_message <> "\"}", 400)
Ok(stub) -> {
let stub_id =
process.call(subject, 5000, fn(reply_subject) {
AddStub(stub, reply_subject)
})
json_response("{\"id\":\"" <> stub_id <> "\"}", 201)
}
}
http.Delete, "/__admin/stubs" -> {
process.call(subject, 5000, fn(reply_subject) {
ClearStubs(reply_subject)
})
json_response("{\"status\":\"ok\"}", 200)
}
http.Get, "/__admin/requests" ->
json_response(
process.call(subject, 5000, fn(reply_subject) {
GetRequests(reply_subject)
}),
200,
)
http.Delete, "/__admin/requests" -> {
process.call(subject, 5000, fn(reply_subject) {
ClearRequests(reply_subject)
})
json_response("{\"status\":\"ok\"}", 200)
}
_, _ -> json_response("{\"error\":\"Not found\"}", 404)
}
}
@target(erlang)
fn build_response(
response_def: ResponseDefinition,
) -> response.Response(mist.ResponseData) {
let body_tree = case response_def.body {
types.NoBody -> bytes_tree.new()
types.StringBody(text) -> bytes_tree.from_string(text)
types.RawJsonBody(json_text) -> bytes_tree.from_string(json_text)
types.BytesBody(bytes) -> bytes_tree.from_bit_array(bytes)
}
let base_response =
response.new(response_def.status)
|> response.set_body(mist.Bytes(body_tree))
list.fold(
response_def.headers,
base_response,
fn(current_response, header_pair) {
let #(key, value) = header_pair
response.set_header(current_response, key, value)
},
)
}
@target(erlang)
fn json_response(
body: String,
status: Int,
) -> response.Response(mist.ResponseData) {
response.new(status)
|> response.set_header("content-type", "application/json")
|> response.set_body(mist.Bytes(bytes_tree.from_string(body)))
}
@target(erlang)
fn insert_stub(stubs: List(Stub), new_stub: Stub) -> List(Stub) {
let without_existing = list.filter(stubs, fn(stub) { stub.id != new_stub.id })
list.sort([new_stub, ..without_existing], fn(left, right) {
case left.priority < right.priority {
True -> order.Lt
False ->
case left.priority > right.priority {
True -> order.Gt
False -> order.Eq
}
}
})
}
@target(erlang)
fn advance_scenario(
scenarios: dict.Dict(String, String),
stub: Stub,
) -> dict.Dict(String, String) {
case stub.scenario {
None -> scenarios
Some(scenario_state) ->
case scenario_state.new_state {
None -> scenarios
Some(new_state) ->
dict.insert(scenarios, scenario_state.name, new_state)
}
}
}
@target(erlang)
@external(erlang, "gleam_stdlib", "identity")
fn identity(value: input) -> output
@target(erlang)
@external(erlang, "erlang", "unique_integer")
fn unique_int() -> Int
@target(erlang)
fn new_id() -> String {
"req_" <> int.to_string(unique_int())
}
@target(erlang)
fn now_ms() -> Int {
erlang_system_time(atom.create("millisecond"))
}
@target(erlang)
@external(erlang, "erlang", "system_time")
fn erlang_system_time(unit: atom.Atom) -> Int
@target(erlang)
@external(erlang, "timer", "sleep")
fn sleep(milliseconds: Int) -> Nil