commit fa03659c0888460f00d45506b9db98de67ffa834 Author: Mikko Ahlroth Date: Sat Jan 28 16:55:39 2023 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..170cca9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +build +erl_crash.dump diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..cd3818b --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +gleam 0.26.1 +erlang 25.1.2 +elixir 1.14.3-otp-25 diff --git a/README.md b/README.md new file mode 100644 index 0000000..332b7d1 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# finch_gleam + +[![Package Version](https://img.shields.io/hexpm/v/finch_gleam)](https://hex.pm/packages/finch_gleam) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/finch_gleam/) + +A Gleam project + +## Quick start + +```sh +gleam run # Run the project +gleam test # Run the tests +gleam shell # Run an Erlang shell +``` + +## Installation + +If available on Hex this package can be added to your Gleam project: + +```sh +gleam add finch_gleam +``` + +and its documentation can be found at . diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..07bc026 --- /dev/null +++ b/gleam.toml @@ -0,0 +1,20 @@ +name = "finch_gleam" +version = "1.0.0" + +# Fill out these fields if you intend to generate HTML documentation or publish +# your project to the Hex package manager. + +licences = ["MIT"] +description = "Gleam wrapper for the Finch HTTP client" +repository = { type = "gitlab", user = "Nicd", repo = "gleam_finch" } +links = [] + +[dependencies] +gleam_stdlib = "~> 0.25" +gleam_erlang = "~> 0.17" +gleam_http = "~> 3.1" +finch = "~> 0.14" + +[dev-dependencies] +gleeunit = "~> 0.7" +mist = "~> 0.9" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..7c799e6 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,28 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "castore", version = "0.1.22", build_tools = ["mix"], requirements = [], otp_app = "castore", source = "hex", outer_checksum = "C17576DF47EB5AA1EE40CC4134316A99F5CAD3E215D5C77B8DD3CFEF12A22CAC" }, + { name = "finch", version = "0.14.0", build_tools = ["mix"], requirements = ["telemetry", "mime", "castore", "mint", "nimble_pool", "nimble_options"], otp_app = "finch", source = "hex", outer_checksum = "5459ACAF18C4FDB47A8C22FB3BAFF5D8173106217C8E56C5BA0B93E66501A8DD" }, + { name = "gleam_erlang", version = "0.17.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "BAAA84F5BCC4477E809BA3E03BB3009A3894A6544C1511626C44408E39DB2AE6" }, + { name = "gleam_http", version = "3.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "B66B7A1539CCB577119E4DC80DD3484C1A652CB032967954498EEDBAE3355763" }, + { name = "gleam_otp", version = "0.5.2", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_erlang"], otp_app = "gleam_otp", source = "hex", outer_checksum = "24B88BF1D5B8DEC2525C00ECB65B96D2FD4DC66D8B2BB4D7AD4D12B2CE2A9988" }, + { name = "gleam_stdlib", version = "0.26.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6221F9D7A08B6D6DBCDD567B2BB7C4B2A7BBF4C04C6110757BE04635143BDEC8" }, + { name = "gleeunit", version = "0.10.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "ECEA2DE4BE6528D36AFE74F42A21CDF99966EC36D7F25DEB34D47DD0F7977BAF" }, + { name = "glisten", version = "0.6.9", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "83CDA9C9FCD316ECF65DBEFAF5690999DDC0FE8CB31696711E7D60FD995E03B7" }, + { name = "hpax", version = "0.1.2", build_tools = ["mix"], requirements = [], otp_app = "hpax", source = "hex", outer_checksum = "2C87843D5A23F5F16748EBE77969880E29809580EFDACCD615CD3BED628A8C13" }, + { name = "mime", version = "2.0.3", build_tools = ["mix"], requirements = [], otp_app = "mime", source = "hex", outer_checksum = "27A30BF0DB44D25EECBA73755ACF4068CBFE26A4372F9EB3E4EA3A45956BFF6B" }, + { name = "mint", version = "1.4.2", build_tools = ["mix"], requirements = ["castore", "hpax"], otp_app = "mint", source = "hex", outer_checksum = "CE75A5BBCC59B4D7D8D70F8B2FC284B1751FFB35C7B6A6302B5192F8AB4DDD80" }, + { name = "mist", version = "0.9.4", build_tools = ["gleam"], requirements = ["glisten", "gleam_erlang", "gleam_otp", "gleam_http", "gleam_stdlib"], otp_app = "mist", source = "hex", outer_checksum = "D327F690A717F588281E636D99E014A90D8EBC2F2B479AA799A1D6AA3899A1B2" }, + { name = "nimble_options", version = "0.5.2", build_tools = ["mix"], requirements = [], otp_app = "nimble_options", source = "hex", outer_checksum = "4DA7F904B915FD71DB549BCDC25F8D56F378EF7AE07DC1D372CBE72BA950DCE0" }, + { name = "nimble_pool", version = "0.2.6", build_tools = ["mix"], requirements = [], otp_app = "nimble_pool", source = "hex", outer_checksum = "1C715055095D3F2705C4E236C18B618420A35490DA94149FF8B580A2144F653F" }, + { name = "telemetry", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "DAD9CE9D8EFFC621708F99EAC538EF1CBE05D6A874DD741DE2E689C47FEAFED5" }, +] + +[requirements] +finch = "~> 0.14" +gleam_erlang = "~> 0.17" +gleam_http = "~> 3.1" +gleam_stdlib = "~> 0.25" +gleeunit = "~> 0.7" +mist = "~> 0.9" diff --git a/src/finch.gleam b/src/finch.gleam new file mode 100644 index 0000000..e84e469 --- /dev/null +++ b/src/finch.gleam @@ -0,0 +1,150 @@ +//// A wrapper module for the Finch HTTP client +//// +//// https://hexdocs.pm/finch + +import gleam +import gleam/uri +import gleam/erlang/atom.{Atom} +import gleam/http +import gleam/http/request as gleam_request +import gleam/http/response as gleam_response +import finch/headers.{Headers} +import finch/stream.{StreamFun} +import finch/otp.{InstanceOptions, OnStart} + +pub type Method { + Head + Get + Put + Post + Patch + Delete + Options +} + +pub type RequestOption { + PoolTimeout(ms: Int) + RequestTimeout(ms: Int) +} + +pub type RequestOptions = + List(RequestOption) + +/// Finch request, use `build/2` to create one from a Gleam request +pub external type Request + +external type Response + +/// Exception returned by Finch in case of request errors +pub external type Exception + +/// Build a Finch request that can be run with `request/2` or `stream/5` +/// +/// Note, the HTTP methods CONNECT, TRACE, and `Other(String)` are not +/// supported, and will be converted to GET. +pub fn build( + req: gleam_request.Request(String), + opts: RequestOptions, +) -> Request { + let url_str = + req + |> gleam_request.to_uri() + |> uri.to_string() + + build_ext( + gleam_method_to_finch_method(req.method), + url_str, + req.headers, + req.body, + opts, + ) +} + +external fn build_ext( + method: Method, + url: String, + headers: Headers, + body: String, + opts: RequestOptions, +) -> Request = + "Elixir.Finch" "build" + +/// Send a request using the Finch server with the given name +pub fn request( + req: Request, + name: Atom, +) -> Result(gleam_response.Response(String), Exception) { + try resp = do_request(req, name) + + gleam.Ok(gleam_response.Response( + body: response_body(resp), + headers: response_headers(resp), + status: response_status(resp), + )) +} + +external fn do_request(req: Request, name: Atom) -> Result(Response, Exception) = + "Elixir.Finch" "request" + +/// Stream a request using the Finch server with the given name +pub external fn stream( + req: Request, + name: Atom, + acc: a, + fun: StreamFun(a), + opts: RequestOptions, +) -> Result(a, Exception) = + "Elixir.Finch" "stream" + +/// Start a new Finch server with the given options +pub external fn start_link(opts: InstanceOptions) -> OnStart = + "Elixir.Finch" "start_link" + +fn response_body(resp: Response) -> String { + get_response_body(resp, Body) +} + +fn response_headers(resp: Response) -> Headers { + get_response_headers(resp, Headers) +} + +fn response_status(resp: Response) -> Int { + get_response_status(resp, Status) +} + +fn gleam_method_to_finch_method(method: http.Method) -> Method { + case method { + http.Get -> Get + http.Head -> Head + http.Post -> Post + http.Put -> Put + http.Patch -> Patch + http.Delete -> Delete + http.Options -> Options + _other -> Get + } +} + +type ResponseBodyKey { + Body +} + +type ResponseHeadersKey { + Headers +} + +type ResponseStatusKey { + Status +} + +external fn get_response_body(resp: Response, key: ResponseBodyKey) -> String = + "Elixir.Map" "fetch!" + +external fn get_response_status(resp: Response, key: ResponseStatusKey) -> Int = + "Elixir.Map" "fetch!" + +external fn get_response_headers( + resp: Response, + key: ResponseHeadersKey, +) -> Headers = + "Elixir.Map" "fetch!" diff --git a/src/finch/headers.gleam b/src/finch/headers.gleam new file mode 100644 index 0000000..f3053a6 --- /dev/null +++ b/src/finch/headers.gleam @@ -0,0 +1,5 @@ +pub type Header = + #(String, String) + +pub type Headers = + List(Header) diff --git a/src/finch/otp.gleam b/src/finch/otp.gleam new file mode 100644 index 0000000..1e92378 --- /dev/null +++ b/src/finch/otp.gleam @@ -0,0 +1,55 @@ +//// Types related to OTP usage of Finch + +import gleam/uri +import gleam/dynamic.{Dynamic} +import gleam/map.{Map} +import gleam/erlang/atom.{Atom} +import gleam/erlang/process.{Pid} +import finch/timeout.{Timeout} + +pub type UnixSocketPath { + Local(path: String) +} + +pub type UnixSocketSpecifier { + Http(path: UnixSocketPath) + Https(path: UnixSocketPath) +} + +pub type PoolSpecifier { + Default + URL(url: uri.Uri) + UnixSocket(specifier: UnixSocketSpecifier) +} + +pub type PoolProtocol { + Http1 + Http2 +} + +pub type PoolOption { + Protocol(PoolProtocol) + Size(Int) + Count(Int) + MaxIdleTime(Timeout) + ConnOpts(List(#(Atom, Dynamic))) + PoolMaxIdleTime(Timeout) + ConnMaxIdleTime(Timeout) +} + +pub type PoolOptions = + List(PoolOption) + +pub type InstanceOption { + Name(Atom) + Pools(Map(PoolSpecifier, PoolOptions)) +} + +pub type InstanceOptions = + List(InstanceOption) + +pub type OnStart { + Ok(child: Pid) + Ignore + Error(Dynamic) +} diff --git a/src/finch/stream.gleam b/src/finch/stream.gleam new file mode 100644 index 0000000..90adfdc --- /dev/null +++ b/src/finch/stream.gleam @@ -0,0 +1,10 @@ +import finch/headers.{Headers as ReqHeaders} + +pub type StreamBlock { + Status(status: Int) + Headers(headers: ReqHeaders) + Data(data: BitString) +} + +pub type StreamFun(a) = + fn(StreamBlock, a) -> a diff --git a/src/finch/timeout.gleam b/src/finch/timeout.gleam new file mode 100644 index 0000000..81679a0 --- /dev/null +++ b/src/finch/timeout.gleam @@ -0,0 +1,25 @@ +//// Wrapper of Elixir's `timeout/0` type +//// +//// See https://hexdocs.pm/elixir/typespecs.html#built-in-types + +import gleam/dynamic + +/// A timeout value, either infinite or a specified amount of milliseconds +pub external type Timeout + +/// Create an infinite timeout +pub fn infinity() -> Timeout { + dynamic.from(Infinity) + |> dynamic.unsafe_coerce() +} + +/// Create a timeout with the given milliseconds +pub fn milliseconds(ms: Int) -> Timeout { + dynamic.from(ms) + |> dynamic.unsafe_coerce() +} + +// Janky way to create the atom "infinity" +type InfinityAtom { + Infinity +} diff --git a/test/finch_gleam_test.gleam b/test/finch_gleam_test.gleam new file mode 100644 index 0000000..1e38c7c --- /dev/null +++ b/test/finch_gleam_test.gleam @@ -0,0 +1,167 @@ +import gleeunit +import gleeunit/should +import mist +import gleam/bit_builder +import gleam/http +import gleam/http/request +import gleam/http/response +import gleam/erlang/atom.{Atom} +import gleam/erlang/process +import gleam/uri +import gleam/string +import gleam/list +import finch +import finch/otp + +const base_url = "http://localhost:33100" + +pub fn main() { + assert Ok(_) = + mist.run_service( + 33_100, + fn(req) { + case req.method, request.path_segments(req) { + http.Get, ["ok"] -> + response.new(200) + |> response.set_body(bit_builder.from_string("OK")) + + http.Post, ["post"] -> + response.new(500) + |> response.set_body(bit_builder.from_bit_string(req.body)) + + http.Delete, ["headers"] -> + response.new(200) + |> response.set_body(bit_builder.from_string(string.inspect( + req.headers, + ))) + |> response.set_header("x-token", "token-x") + } + }, + max_body_limit: 4_000_000, + ) + + configure_logger([Level(None)]) + + gleeunit.main() +} + +pub fn ok_req_test() { + use <- with_finch() + + assert Ok(url) = uri.parse(ok_url()) + assert Ok(req) = request.from_uri(url) + let finch_req = finch.build(req, []) + assert Ok(resp) = finch.request(finch_req, server_name()) + + should.equal(resp.status, 200) + should.equal(resp.body, "OK") +} + +pub fn post_data_test() { + let body = "FOOBAR" + + use <- with_finch() + + assert Ok(url) = uri.parse(post_url()) + assert Ok(req) = request.from_uri(url) + let req = request.Request(..req, method: http.Post, body: body) + let finch_req = finch.build(req, []) + assert Ok(resp) = finch.request(finch_req, server_name()) + + should.equal(resp.status, 500) + should.equal(resp.body, body) +} + +pub fn headers_test() { + use <- with_finch() + + assert Ok(url) = uri.parse(headers_url()) + assert Ok(req) = request.from_uri(url) + let req = + request.Request( + ..req, + method: http.Delete, + headers: [ + #("accept", "multipart/form-data"), + #("content-type", "formipart/mult-data"), + ], + ) + let finch_req = finch.build(req, []) + assert Ok(resp) = finch.request(finch_req, server_name()) + + should.equal(resp.status, 200) + should.be_true(string.contains( + resp.body, + "#(\"accept\", \"multipart/form-data\")", + )) + should.be_true(string.contains( + resp.body, + "#(\"content-type\", \"formipart/mult-data\")", + )) + should.be_true(list.contains(resp.headers, #("x-token", "token-x"))) +} + +fn ok_url() { + base_url <> "/ok" +} + +fn post_url() { + base_url <> "/post" +} + +fn headers_url() { + base_url <> "/headers" +} + +fn with_finch(test: fn() -> a) { + let subject: process.Subject(process.Pid) = process.new_subject() + + process.start( + fn() { + assert otp.Ok(child) = start_finch() + process.send(subject, child) + process.sleep_forever() + }, + False, + ) + + assert Ok(finch_pid) = process.receive(subject, 1000) + + let monitor = process.monitor_process(finch_pid) + let selector = + process.new_selector() + |> process.selecting_process_down(monitor, fn(down) { down }) + + test() + + process.kill(finch_pid) + assert Ok(_) = process.select(selector, 1000) + + // We need to wait for all the Finch processes to close, sadly didn't find a + // better way to do this :/ + process.sleep(200) +} + +fn start_finch() -> otp.OnStart { + let name = server_name() + + finch.start_link([otp.Name(name)]) +} + +fn server_name() -> Atom { + atom.create_from_string("finch_test_server") +} + +type LogLevel { + None +} + +type LoggerOption { + Level(LogLevel) +} + +type LoggerOptions = + List(LoggerOption) + +external fn configure_logger(LoggerOptions) -> Nil = + "Elixir.Logger" "configure"