diff --git a/db/migrations/20240525044318_create_users.sql b/db/migrations/20240525044318_create_users.sql index 627d92f..11f9e84 100644 --- a/db/migrations/20240525044318_create_users.sql +++ b/db/migrations/20240525044318_create_users.sql @@ -3,6 +3,7 @@ CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); diff --git a/gleam.toml b/gleam.toml index 3db8fc3..b0d9b21 100644 --- a/gleam.toml +++ b/gleam.toml @@ -13,14 +13,15 @@ version = "1.0.0" # https://gleam.run/writing-gleam/gleam-toml/. [dependencies] -gleam_stdlib = ">= 0.34.0 and < 2.0.0" -wisp = ">= 0.14.0 and < 1.0.0" -mist = { path = "../../mist" } -gleam_http = ">= 3.6.0 and < 4.0.0" +antigone = ">= 1.1.0 and < 2.0.0" +envoy = ">= 1.0.1 and < 2.0.0" gleam_erlang = ">= 0.25.0 and < 1.0.0" +gleam_http = ">= 3.6.0 and < 4.0.0" gleam_json = ">= 1.0.1 and < 2.0.0" -envoy = ">= 1.0.1 and < 2.0.0" gleam_pgo = ">= 0.9.0 and < 1.0.0" +gleam_stdlib = ">= 0.34.0 and < 2.0.0" +mist = ">= 1.2.0 and < 2.0.0" +wisp = ">= 0.14.0 and < 1.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 5c45bd6..91860d2 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,8 +2,14 @@ # You typically do not need to edit this file packages = [ + { name = "antigone", version = "1.1.0", build_tools = ["gleam"], requirements = ["argon2_elixir"], otp_app = "antigone", source = "hex", outer_checksum = "2AF72F94924FD2C1C732DF714F756BAB1B04E7128569F5E2785179A17691FC0D" }, + { name = "argon2_elixir", version = "4.0.0", build_tools = ["mix", "make"], requirements = ["comeonin", "elixir_make"], otp_app = "argon2_elixir", source = "hex", outer_checksum = "F9DA27CF060C9EA61B1BD47837A28D7E48A8F6FA13A745E252556C14F9132C7F" }, { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, { name = "birl", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "B1FA529E7BE3FF12CADF32814AB8EC7294E74CEDEE8CC734505707B929A98985" }, + { name = "castore", version = "1.0.7", build_tools = ["mix"], requirements = [], otp_app = "castore", source = "hex", outer_checksum = "DA7785A4B0D2A021CD1292A60875A784B6CAEF71E76BF4917BDEE1F390455CF5" }, + { name = "certifi", version = "2.13.0", build_tools = ["rebar3"], requirements = [], otp_app = "certifi", source = "hex", outer_checksum = "8F3D9533A0F06070AFDFD5D596B32E21C6580667A492891851B0E2737BC507A1" }, + { name = "comeonin", version = "5.4.0", build_tools = ["mix"], requirements = [], otp_app = "comeonin", source = "hex", outer_checksum = "796393A9E50D01999D56B7B8420AB0481A7538D0CAF80919DA493B4A6E51FAF1" }, + { name = "elixir_make", version = "0.8.4", build_tools = ["mix"], requirements = ["castore", "certifi"], otp_app = "elixir_make", source = "hex", outer_checksum = "6E7F1D619B5F61DFABD0A20AA268E575572B542AC31723293A4C1A567D5EF040" }, { name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" }, { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, @@ -16,11 +22,11 @@ packages = [ { name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" }, { name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" }, { name = "glisten", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib"], otp_app = "glisten", source = "hex", outer_checksum = "CF3A9383E9BA4A8CBAF2F7B799716290D02F2AC34E7A77556B49376B662B9314" }, - { name = "gramps", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "99A1BC8462E502282097D92CD7053043B714B91DC78E8AEB7D86AB8FF71E016F" }, + { name = "gramps", version = "2.0.3", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "3CCAA6E081225180D95C79679D383BBF51C8D1FDC1B84DA1DA444F628C373793" }, { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, { name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" }, { name = "marceau", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "1AAD727A30BE0F95562C3403BB9B27C823797AD90037714255EEBF617B1CDA81" }, - { name = "mist", version = "1.0.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], source = "local", path = "../../mist" }, + { name = "mist", version = "1.2.0", build_tools = ["gleam"], requirements = ["birl", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "109B4D64E68C104CC23BB3CC5441ECD479DD7444889DA01113B75C6AF0F0E17B" }, { name = "opentelemetry_api", version = "1.3.0", build_tools = ["rebar3", "mix"], requirements = ["opentelemetry_semantic_conventions"], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "B9E5FF775FD064FA098DBA3C398490B77649A352B40B0B730A6B7DC0BDD68858" }, { name = "opentelemetry_semantic_conventions", version = "0.2.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_semantic_conventions", source = "hex", outer_checksum = "D61FA1F5639EE8668D74B527E6806E0503EFC55A42DB7B5F39939D84C07D6895" }, { name = "pg_types", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "B02EFA785CAECECF9702C681C80A9CA12A39F9161A846CE17B01FB20AEEED7EB" }, @@ -32,12 +38,13 @@ packages = [ ] [requirements] +antigone = { version = ">= 1.1.0 and < 2.0.0"} envoy = { version = ">= 1.0.1 and < 2.0.0" } gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" } gleam_http = { version = ">= 3.6.0 and < 4.0.0" } gleam_json = { version = ">= 1.0.1 and < 2.0.0" } -gleam_pgo = { version = ">= 0.9.0 and < 1.0.0"} +gleam_pgo = { version = ">= 0.9.0 and < 1.0.0" } gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -mist = { path = "../../mist" } +mist = { version = ">= 1.2.0 and < 2.0.0" } wisp = { version = ">= 0.14.0 and < 1.0.0" } diff --git a/src/app/contexts/user_contexts.gleam b/src/app/contexts/user_contexts.gleam new file mode 100644 index 0000000..fa07f0b --- /dev/null +++ b/src/app/contexts/user_contexts.gleam @@ -0,0 +1,39 @@ +import gleam/dynamic.{type DecodeError, type Dynamic} +import gleam/result + +pub type NewUser { + NewUser(name: String, email: String, password: String) +} + +pub type User { + User(id: Int, name: String, email: String, password_hash: String) +} + +// Decodes the JSON blob from the POST to /users +pub fn new_user_decoder(json: Dynamic) -> Result(NewUser, Nil) { + let decoder = + dynamic.decode3( + NewUser, + dynamic.field("name", dynamic.string), + dynamic.field("email", dynamic.string), + dynamic.field("password", dynamic.string), + ) + + json + |> decoder() + |> result.nil_error() +} + +// Decodes the return from the db insert +pub fn user_decoder(tuple: Dynamic) -> Result(User, List(DecodeError)) { + let decoder = + dynamic.decode4( + User, + dynamic.element(0, dynamic.int), + dynamic.element(1, dynamic.string), + dynamic.element(2, dynamic.string), + dynamic.element(3, dynamic.string), + ) + + decoder(tuple) +} diff --git a/src/app/queries/user_queries.gleam b/src/app/queries/user_queries.gleam new file mode 100644 index 0000000..7ec8c77 --- /dev/null +++ b/src/app/queries/user_queries.gleam @@ -0,0 +1,28 @@ +import antigone +import app/contexts/user_contexts.{type NewUser, type User, user_decoder} +import gleam/bit_array +import gleam/pgo.{type Connection, Returned} + +pub fn create_user(connection: Connection, user: NewUser) -> Result(User, Nil) { + let sql = + " + INSERT INTO users (name, email, password_hash) + VALUES ($1, $2, $3) + RETURNING id, name, email, password_hash; +" + let password = bit_array.from_string(user.password) + let hashed_password = antigone.hash(antigone.hasher(), password) + + let returned = + pgo.execute( + sql, + connection, + [pgo.text(user.name), pgo.text(user.email), pgo.text(hashed_password)], + user_decoder, + ) + + case returned { + Ok(Returned(_, [user, ..])) -> Ok(user) + _ -> Error(Nil) + } +} diff --git a/src/app/rest/user_endpoints.gleam b/src/app/rest/user_endpoints.gleam new file mode 100644 index 0000000..aeac3d2 --- /dev/null +++ b/src/app/rest/user_endpoints.gleam @@ -0,0 +1,11 @@ +import app/contexts/web_contexts.{type Context} +import app/services/user_services.{create_user} +import gleam/http.{Post} +import wisp.{type Request, type Response} + +pub fn all(req: Request, ctx: Context) -> Response { + case req.method { + Post -> create_user(req, ctx) + _ -> wisp.method_not_allowed([Post]) + } +} diff --git a/src/app/router.gleam b/src/app/router.gleam index 1d661cb..37c186e 100644 --- a/src/app/router.gleam +++ b/src/app/router.gleam @@ -1,5 +1,6 @@ +import app/contexts/web_contexts.{type Context} import app/rest/organization_endpoints as organizations -import app/types/web_types.{type Context} +import app/rest/user_endpoints as users import app/web.{middleware} import wisp.{type Request, type Response} @@ -8,6 +9,7 @@ pub fn handle_request(req: Request, ctx: Context) -> Response { case wisp.path_segments(req) { ["organizations"] -> organizations.all(req, ctx) + ["users"] -> users.all(req, ctx) _ -> wisp.not_found() } } diff --git a/src/app/services/user_services.gleam b/src/app/services/user_services.gleam new file mode 100644 index 0000000..cba8376 --- /dev/null +++ b/src/app/services/user_services.gleam @@ -0,0 +1,36 @@ +import app/contexts/user_contexts as contexts +import app/contexts/web_contexts.{type Context} +import app/queries/user_queries as queries +import gleam/json +import gleam/result.{try} +import wisp.{type Request, type Response} + +pub fn create_user(req: Request, ctx: Context) -> Response { + use json <- wisp.require_json(req) + + let result = { + // Decode the JSON into a NewUser record. + use new_user <- try(contexts.new_user_decoder(json)) + + // Save the user to the database. + use user <- try(queries.create_user(ctx.db, new_user)) + + // Construct a JSON payload with the id and name of the newly created user. + // TODO: case on the user result, return Ok or Error + Ok( + json.to_string_builder( + json.object([ + #("id", json.int(user.id)), + #("name", json.string(user.name)), + #("email", json.string(user.email)), + #("password_hash", json.string(user.password_hash)), + ]), + ), + ) + } + + case result { + Ok(json) -> wisp.json_response(json, 201) + Error(Nil) -> wisp.unprocessable_entity() + } +}