Skip to main content

src/http_server_mock/internal/server_impl.gleam

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