summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-06-27 03:28:54 +0700
committerpolwex <polwex@sortug.com>2025-06-27 03:28:54 +0700
commitba350f124bab36766af6c71ba5e3dc17f33fb5ab (patch)
tree2b242e86cd8c30db058d110c5a4b7864f45f5be3
init
-rw-r--r--.envrc1
-rw-r--r--.gitignore4
-rw-r--r--.ocamlformat1
-rw-r--r--CLAUDE.md25
-rw-r--r--NOTES.md23
-rw-r--r--bin/dune15
-rw-r--r--bin/server.ml53
-rw-r--r--bs5.opam31
-rw-r--r--dune-project26
-rw-r--r--flake.lock113
-rw-r--r--flake.nix66
-rw-r--r--init.sql40
-rw-r--r--lib/dune12
-rw-r--r--lib/handler.ml162
-rw-r--r--lib/pages.ml33
-rw-r--r--lib/post_handlers.ml100
-rw-r--r--lib/query.ml159
-rw-r--r--lib/router.ml84
-rw-r--r--run.sh3
-rw-r--r--test/dune2
-rw-r--r--test/test_bs5.ml0
21 files changed, 953 insertions, 0 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c9a6693
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.direnv
+_build
+gatling
+bulkdata
diff --git a/.ocamlformat b/.ocamlformat
new file mode 100644
index 0000000..0619e80
--- /dev/null
+++ b/.ocamlformat
@@ -0,0 +1 @@
+profile = janestreet
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b849fa7
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,25 @@
+# OCaml Routes Library DSL
+
+## The `@-->` Operator
+
+The `@-->` operator is from the `routes` library and is used to bind route patterns to handler functions.
+
+### Example:
+```ocaml
+`GET, (s "posts" /? nil) @--> Handler.get_posts
+```
+
+### Breaking it down:
+- `s "posts"` - matches the string "posts" in the URL path
+- `/?` - path concatenation operator
+- `nil` - end of path (no more segments)
+- `@-->` - "maps to" operator that binds the route to the handler
+
+So `(s "posts" /? nil) @--> Handler.get_posts` means "the route `/posts` maps to the `Handler.get_posts` function".
+
+### Other common operators in the routes library:
+- `/:` for path parameters (e.g., `s "user" /: int /? nil` matches `/user/123`)
+- `//` for wildcard paths
+- `<$>` for transforming matched values
+
+It's a DSL (domain-specific language) for expressing routes in a concise, type-safe way. \ No newline at end of file
diff --git a/NOTES.md b/NOTES.md
new file mode 100644
index 0000000..0fd67b5
--- /dev/null
+++ b/NOTES.md
@@ -0,0 +1,23 @@
+apparently janestreet style is to use double semicolons
+
+# Cline's Notes
+
+## Objective: Fix the `litedb` build
+
+The user is experiencing a build failure with the `litedb` library. The error message "The module Litedb.Router is an alias for module Litedb__Router, which is missing" indicates a problem with how `dune` is resolving the modules within the library.
+
+### What I've tried:
+
+1. **Adding `(modules ...)` to `litedb/dune`:** This was incorrect, as the `lib` directory works without it.
+2. **Creating `litedb/litedb.ml`:** This was also incorrect, as the `lib` directory doesn't have a main module file.
+3. **Modifying `bin/mainlite.ml`:** I've tried various ways of referencing the `Litedb` modules, but the error persists.
+
+### What I've learned:
+
+* The `lib` and `litedb` directories have nearly identical `dune` files.
+* The `lib` directory works correctly without a main module file or an explicit `(modules ...)` stanza.
+* The error is specific to the `litedb` library.
+
+### Next Steps:
+
+I need to re-evaluate my understanding of how `dune` resolves modules. I will now try to run the application again, but this time I will pay close attention to the build output to see if there are any other clues that I've missed.
diff --git a/bin/dune b/bin/dune
new file mode 100644
index 0000000..6f90a60
--- /dev/null
+++ b/bin/dune
@@ -0,0 +1,15 @@
+(executable
+ (public_name bs5)
+ (name server)
+ (modules server)
+ (libraries
+ ; local
+ lib
+ ;
+ piaf
+ routes
+ eio_main
+ logs.fmt
+ fmt.tty
+ logs.threaded
+ caqti-driver-sqlite3))
diff --git a/bin/server.ml b/bin/server.ml
new file mode 100644
index 0000000..b5cdd84
--- /dev/null
+++ b/bin/server.ml
@@ -0,0 +1,53 @@
+open Eio
+open Piaf
+
+let setup_log ?style_renderer level =
+ Logs_threaded.enable ();
+ Fmt_tty.setup_std_outputs ?style_renderer ();
+ Logs.set_level ~all:true level;
+ Logs.set_reporter (Logs_fmt.reporter ())
+;;
+
+let request_handler ~db_pool Server.{ request; _ } =
+ match Lib.Router.match_route request.meth request.target with
+ | Some handler -> Result.get_ok @@ handler db_pool request
+ | None ->
+ Logs.info (fun d -> d "the fuck %S\n" request.target);
+ Response.create `Not_found
+;;
+
+let () =
+ setup_log (Some Logs.Info);
+ Eio_main.run
+ @@ fun env ->
+ Switch.run
+ @@ fun sw ->
+ let config =
+ let interface = Net.Ipaddr.V4.any in
+ let port = 4455 in
+ `Tcp (interface, port)
+ in
+ let config = Server.Config.create config in
+ let db_uri =
+ Uri.make ~scheme:"sqlite3" ~path:"/home/y/code/ocaml/bs5/bulkdata/blog.db" ()
+ in
+ (* Create connection pool with initialization function *)
+ let connect_pool ~sw ~stdenv uri =
+ Caqti_eio_unix.connect_pool
+ ~sw
+ ~stdenv
+ ~post_connect:(fun conn ->
+ (* Initialize each connection with SQLite performance pragmas *)
+ match Lib.Query.init_connection conn with
+ | Ok () -> Ok ()
+ | Error err -> Error err)
+ uri
+ in
+ match connect_pool ~sw ~stdenv:(env :> Caqti_eio.stdenv) db_uri with
+ (* match Caqti_eio_unix.connect_pool ~sw ~stdenv:(env :> Caqti_eio.stdenv) db_uri with *)
+ | Ok pool ->
+ let server = Server.create ~config (request_handler ~db_pool:pool) in
+ ignore @@ Server.Command.start ~sw env server
+ | Error err ->
+ Logs.err (fun m -> m "Error connecting to database: %a" Caqti_error.pp err)
+;;
diff --git a/bs5.opam b/bs5.opam
new file mode 100644
index 0000000..3b957d2
--- /dev/null
+++ b/bs5.opam
@@ -0,0 +1,31 @@
+# This file is generated by dune, edit dune-project instead
+opam-version: "2.0"
+synopsis: "A short synopsis"
+description: "A longer description"
+maintainer: ["Maintainer Name"]
+authors: ["Author Name"]
+license: "LICENSE"
+tags: ["topics" "to describe" "your" "project"]
+homepage: "https://github.com/username/reponame"
+doc: "https://url/to/documentation"
+bug-reports: "https://github.com/username/reponame/issues"
+depends: [
+ "ocaml"
+ "dune" {>= "3.13"}
+ "odoc" {with-doc}
+]
+build: [
+ ["dune" "subst"] {dev}
+ [
+ "dune"
+ "build"
+ "-p"
+ name
+ "-j"
+ jobs
+ "@install"
+ "@runtest" {with-test}
+ "@doc" {with-doc}
+ ]
+]
+dev-repo: "git+https://github.com/username/reponame.git"
diff --git a/dune-project b/dune-project
new file mode 100644
index 0000000..b78e6f4
--- /dev/null
+++ b/dune-project
@@ -0,0 +1,26 @@
+(lang dune 3.13)
+
+(name bs5)
+
+(generate_opam_files true)
+
+(source
+ (github username/reponame))
+
+(authors "Author Name")
+
+(maintainers "Maintainer Name")
+
+(license LICENSE)
+
+(documentation https://url/to/documentation)
+
+(package
+ (name bs5)
+ (synopsis "A short synopsis")
+ (description "A longer description")
+ (depends ocaml dune)
+ (tags
+ (topics "to describe" your project)))
+
+; See the complete stanza docs at https://dune.readthedocs.io/en/stable/dune-files.html#dune-project
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..ed1eb1b
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,113 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1705309234,
+ "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "flake-utils_2": {
+ "inputs": {
+ "systems": "systems_2"
+ },
+ "locked": {
+ "lastModified": 1705309234,
+ "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "inputs": {
+ "flake-utils": "flake-utils_2",
+ "nixpkgs": "nixpkgs_2"
+ },
+ "locked": {
+ "lastModified": 1705609824,
+ "narHash": "sha256-ARrxzdKiiMsAs2zQzxe+XkxTo/eg1FpmvWhFoDgYB/A=",
+ "owner": "nix-ocaml",
+ "repo": "nix-overlays",
+ "rev": "0477e3c5a2c05351707a3e36c462aef4c78f769f",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-ocaml",
+ "repo": "nix-overlays",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1705429789,
+ "narHash": "sha256-7gQju9WiToi7wI6oahTXiqwJu2RZoV0cg8OGa9YhEvw=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "cc3ab0e45687d15cb21663a95f5a53a05abd39e4",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "cc3ab0e45687d15cb21663a95f5a53a05abd39e4",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ },
+ "systems_2": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..899547c
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,66 @@
+{
+ inputs = {
+ flake-utils.url = "github:numtide/flake-utils";
+ nixpkgs.url = "github:nix-ocaml/nix-overlays";
+ };
+
+ outputs = {
+ self,
+ nixpkgs,
+ flake-utils,
+ }:
+ flake-utils.lib.eachDefaultSystem (system: let
+ pkgs = nixpkgs.legacyPackages.${system}.appendOverlays [
+ (self: super: {
+ ocamlPackages =
+ super.ocaml-ng.ocamlPackages_5_1.overrideScope'
+ (oself: osuper: {
+ pg_query = osuper.pg_query.overrideAttrs (prev: {
+ propagatedBuildInputs =
+ prev.propagatedBuildInputs
+ ++ [osuper.cmdliner];
+ });
+ });
+ })
+ ];
+
+ combattant = pkgs.ocamlPackages.buildDunePackage {
+ pname = "combattant";
+ version = "0.0.1";
+ src = ./.;
+ buildInputs = with pkgs.ocamlPackages; [
+ ppx_rapper
+ ppx_rapper_eio
+ yojson
+ eio_main
+ piaf
+ routes
+ caqti-dynload
+ caqti-driver-postgresql
+ caqti-driver-sqlite3
+ pkgs.sqlite
+ ppx_expect
+
+ logs
+ ];
+ };
+ in {
+ devShells.default = pkgs.mkShell rec {
+ nativeBuildInputs = with pkgs.ocamlPackages; [
+ dune_3
+ findlib
+ ocaml
+ ocaml-lsp
+ ocamlformat
+ pkgs.openjdk17
+ pkgs.sqlite
+ ];
+
+ buildInputs =
+ combattant.buildInputs
+ ++ (with pkgs.ocamlPackages; [utop]);
+ };
+
+ packages.default = combattant;
+ });
+}
diff --git a/init.sql b/init.sql
new file mode 100644
index 0000000..a02895a
--- /dev/null
+++ b/init.sql
@@ -0,0 +1,40 @@
+CREATE TABLE clients (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(50) NOT NULL,
+ mov_limit INTEGER NOT NULL
+);
+
+CREATE TYPE transaction_type AS ENUM ('credit', 'debit');
+
+CREATE TABLE transactions (
+ id SERIAL PRIMARY KEY,
+ client_id INTEGER REFERENCES clients,
+ value INTEGER NOT NULL,
+ type transaction_type NOT NULL,
+ description VARCHAR(10) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_client_id_transactions ON transactions (client_id);
+
+CREATE TABLE balances (
+ id SERIAL PRIMARY KEY,
+ client_id INTEGER REFERENCES clients,
+ value INTEGER NOT NULL
+);
+
+CREATE INDEX idx_client_id_balances ON balances (client_id);
+
+DO $$
+BEGIN
+ INSERT INTO clients (name, mov_limit)
+ VALUES
+ ('naruto', 100000),
+ ('mob', 80000),
+ ('jojo', 1000000),
+ ('hellboy', 10000000),
+ ('ultramega', 500000);
+ INSERT INTO balances (client_id, value)
+ SELECT id, 0 FROM clients;
+END;
+$$
diff --git a/lib/dune b/lib/dune
new file mode 100644
index 0000000..9064fed
--- /dev/null
+++ b/lib/dune
@@ -0,0 +1,12 @@
+(library
+ (name lib)
+ (libraries
+ piaf
+ routes
+ yojson
+ caqti
+ caqti-driver-sqlite3
+ caqti-eio
+ ppx_rapper_eio)
+ (preprocess
+ (pps ppx_rapper)))
diff --git a/lib/handler.ml b/lib/handler.ml
new file mode 100644
index 0000000..3408184
--- /dev/null
+++ b/lib/handler.ml
@@ -0,0 +1,162 @@
+open Piaf
+
+type pool = ((module Rapper_helper.CONNECTION), Caqti_error.t) Caqti_eio.Pool.t
+
+(* This is the main handler function for the GET /posts endpoint. *)
+(* It takes a database connection pool and a Piaf request as input. *)
+let get_posts (db_pool : pool) (_request : Request.t) =
+ (* Use a connection from the pool. Caqti_eio.Pool.use handles acquiring and releasing the connection. *)
+ Caqti_eio.Pool.use
+ (fun conn ->
+ (* Call the get_poasts function from the Query module to fetch posts from the database. *)
+ let posts_or_error = Query.get_poasts () conn in
+ (* Pattern match on the result of the database query. *)
+ match posts_or_error with
+ (* If the query is successful, the result is a list of posts. *)
+ | Ok posts ->
+ (* Map the list of post tuples to a Yojson list. *)
+ let json =
+ `List
+ ((* For each post tuple, create a JSON object. *)
+ List.map
+ (fun (post : Query.post_summary) -> `Assoc [ "title", `String post.title ])
+ posts)
+ in
+ (* Return a 200 OK response with the JSON body. *)
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ (* If the query fails, log the error and return a 500 Internal Server Error response. *)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
+
+(* Handler for GET /posts/:id - gets a single post by ID *)
+let get_post post_id (db_pool : pool) (_request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ let post_or_error = Query.get_poast post_id conn in
+ match post_or_error with
+ | Ok (Some post) ->
+ let json =
+ `Assoc
+ [ "id", `Int post.id
+ ; "title", `String post.title
+ ; "content", `String post.content
+ ; "date", `String post.date
+ ; "tags", `String post.tags
+ ; "url", `String post.url
+ ]
+ in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ | Ok None -> Ok (Response.create `Not_found)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
+
+(* Handler for GET /comments/:id - gets a single comment by ID *)
+let get_comment comment_id (db_pool : pool) (_request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ let comment_or_error = Query.Query.comment ~id:comment_id conn in
+ match comment_or_error with
+ | Ok (Some comment) ->
+ let json =
+ `Assoc
+ [ "id", `Int comment.id
+ ; "content", `String comment.content
+ ; "date", `String comment.date
+ ; "tags", `String comment.tags
+ ; "url", `String comment.url
+ ]
+ in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ | Ok None -> Ok (Response.create `Not_found)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
+
+(* Handler for GET /users/:username/comments - gets comments by a user *)
+let get_user_comments username (db_pool : pool) (_request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ let comments_or_error = Query.Query.user_comments ~username conn in
+ match comments_or_error with
+ | Ok comments ->
+ let json =
+ `List
+ (List.map
+ (fun (comment : Query.comment) ->
+ `Assoc
+ [ "id", `Int comment.id
+ ; "content", `String comment.content
+ ; "date", `String comment.date
+ ; "tags", `String comment.tags
+ ; "url", `String comment.url
+ ])
+ comments)
+ in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
+
+(* Handler for GET /posts/:id/comments - gets comments for a post *)
+let get_post_comments post_id (db_pool : pool) (_request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ let comments_or_error = Query.Query.post_comments ~post_id conn in
+ match comments_or_error with
+ | Ok comments ->
+ let json =
+ `List
+ (List.map
+ (fun (comment : Query.comment) ->
+ `Assoc
+ [ "id", `Int comment.id
+ ; "content", `String comment.content
+ ; "date", `String comment.date
+ ; "tags", `String comment.tags
+ ; "url", `String comment.url
+ ])
+ comments)
+ in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
+
+(* Handler for GET /comments/:id/children - gets child comments *)
+let get_comment_children parent_id (db_pool : pool) (_request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ let comments_or_error = Query.Query.comment_children ~post_id:parent_id conn in
+ match comments_or_error with
+ | Ok comments ->
+ let json =
+ `List
+ (List.map
+ (fun (comment : Query.comment) ->
+ `Assoc
+ [ "id", `Int comment.id
+ ; "content", `String comment.content
+ ; "date", `String comment.date
+ ; "tags", `String comment.tags
+ ; "url", `String comment.url
+ ])
+ comments)
+ in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
diff --git a/lib/pages.ml b/lib/pages.ml
new file mode 100644
index 0000000..e52332f
--- /dev/null
+++ b/lib/pages.ml
@@ -0,0 +1,33 @@
+open Piaf
+
+type pool = ((module Rapper_helper.CONNECTION), Caqti_error.t) Caqti_eio.Pool.t
+
+(* This is the main handler function for the GET /posts endpoint. *)
+(* It takes a database connection pool and a Piaf request as input. *)
+let get_root (db_pool : pool) (request : Request.t) =
+ let _coki = Piaf.Cookies.Cookie.parse request.headers in
+ (* Use a connection from the pool. Caqti_eio.Pool.use handles acquiring and releasing the connection. *)
+ Caqti_eio.Pool.use
+ (fun conn ->
+ (* Call the get_poasts function from the Query module to fetch posts from the database. *)
+ let posts_or_error = Query.get_poasts () conn in
+ (* Pattern match on the result of the database query. *)
+ match posts_or_error with
+ (* If the query is successful, the result is a list of posts. *)
+ | Ok posts ->
+ (* Map the list of post tuples to a Yojson list. *)
+ let json =
+ `List
+ ((* For each post tuple, create a JSON object. *)
+ List.map
+ (fun (post : Query.post_summary) -> `Assoc [ "title", `String post.title ])
+ posts)
+ in
+ (* Return a 200 OK response with the JSON body. *)
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string json) `OK)
+ (* If the query fails, log the error and return a 500 Internal Server Error response. *)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error))
+ db_pool
+;;
diff --git a/lib/post_handlers.ml b/lib/post_handlers.ml
new file mode 100644
index 0000000..2035792
--- /dev/null
+++ b/lib/post_handlers.ml
@@ -0,0 +1,100 @@
+open Piaf
+
+type pool = ((module Rapper_helper.CONNECTION), Caqti_error.t) Caqti_eio.Pool.t
+
+(* POST handlers for creating resources *)
+
+(* Handler for POST /posts - creates a new post *)
+let create_post (db_pool : pool) (request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ (* Parse JSON body *)
+ match Body.to_string request.body with
+ | Error _ -> Ok (Response.create `Bad_request)
+ | Ok body_str ->
+ try
+ let json = Yojson.Safe.from_string body_str in
+ let open Yojson.Safe.Util in
+ let title = json |> member "title" |> to_string in
+ let content = json |> member "content" |> to_string in
+ let tags = json |> member "tags" |> to_string_option |> Option.value ~default:"" in
+ let url = json |> member "url" |> to_string_option |> Option.value ~default:"" in
+ let date = Unix.time () |> Unix.gmtime |> fun tm ->
+ Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d"
+ (tm.tm_year + 1900) (tm.tm_mon + 1) tm.tm_mday
+ tm.tm_hour tm.tm_min tm.tm_sec in
+ (* Insert the post *)
+ match Query.create_post ~title ~content ~date ~tags ~url conn with
+ | Ok () ->
+ let response_json = `Assoc ["message", `String "Post created successfully"] in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string response_json) `Created)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error)
+ with
+ | Yojson.Json_error _ -> Ok (Response.create `Bad_request)
+ | _ -> Ok (Response.create `Bad_request))
+ db_pool
+;;
+
+(* Handler for POST /comments - creates a new comment *)
+let create_comment (db_pool : pool) (request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ match Body.to_string request.body with
+ | Error _ -> Ok (Response.create `Bad_request)
+ | Ok body_str ->
+ try
+ let json = Yojson.Safe.from_string body_str in
+ let open Yojson.Safe.Util in
+ let content = json |> member "content" |> to_string in
+ let post_id = json |> member "post_id" |> to_int in
+ let parent = json |> member "parent" |> to_int_option in
+ let author = json |> member "author" |> to_string in
+ let tags = json |> member "tags" |> to_string_option |> Option.value ~default:"" in
+ let url = json |> member "url" |> to_string_option |> Option.value ~default:"" in
+ let date = Unix.time () |> Unix.gmtime |> fun tm ->
+ Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d"
+ (tm.tm_year + 1900) (tm.tm_mon + 1) tm.tm_mday
+ tm.tm_hour tm.tm_min tm.tm_sec in
+ (* Insert the comment *)
+ match Query.create_comment ~content ~date ~tags ~url ~post_id ?parent ~author conn with
+ | Ok () ->
+ let response_json = `Assoc ["message", `String "Comment created successfully"] in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string response_json) `Created)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error)
+ with
+ | Yojson.Json_error _ -> Ok (Response.create `Bad_request)
+ | _ -> Ok (Response.create `Bad_request))
+ db_pool
+;;
+
+(* Handler for POST /votes - creates a new vote *)
+let create_vote (db_pool : pool) (request : Request.t) =
+ Caqti_eio.Pool.use
+ (fun conn ->
+ match Body.to_string request.body with
+ | Error _ -> Ok (Response.create `Bad_request)
+ | Ok body_str ->
+ try
+ let json = Yojson.Safe.from_string body_str in
+ let open Yojson.Safe.Util in
+ let user_id = json |> member "user_id" |> to_string in
+ let vote_type = json |> member "vote_type" |> to_string in
+ let post_id = json |> member "post_id" |> to_int_option in
+ let comment_id = json |> member "comment_id" |> to_int_option in
+ (* Insert the vote *)
+ match Query.create_vote ~user_id ~vote_type ?post_id ?comment_id conn with
+ | Ok () ->
+ let response_json = `Assoc ["message", `String "Vote created successfully"] in
+ Ok (Response.of_string ~body:(Yojson.Safe.to_string response_json) `Created)
+ | Error err ->
+ Logs.err (fun m -> m "Database error: %a" Caqti_error.pp err);
+ Ok (Response.create `Internal_server_error)
+ with
+ | Yojson.Json_error _ -> Ok (Response.create `Bad_request)
+ | _ -> Ok (Response.create `Bad_request))
+ db_pool
+;; \ No newline at end of file
diff --git a/lib/query.ml b/lib/query.ml
new file mode 100644
index 0000000..70285fc
--- /dev/null
+++ b/lib/query.ml
@@ -0,0 +1,159 @@
+(* Define record types for the query outputs *)
+type post_summary = {
+ id: int;
+ title: string;
+ content: string;
+ date: string;
+}
+
+type post = {
+ id: int;
+ title: string;
+ content: string;
+ date: string;
+ tags: string;
+ url: string;
+}
+
+type comment = {
+ id: int;
+ content: string;
+ date: string;
+ tags: string;
+ url: string;
+}
+
+module Query = struct
+ let poasts =
+ [%rapper
+ get_many
+ {sql|
+ SELECT @int{id}, @string{title}, @string{content}, @string{date}
+ FROM Posts
+ ORDER BY id DESC LIMIT 100
+ |sql}
+ record_out]
+ ;;
+
+ let poast =
+ [%rapper
+ get_opt
+ {sql|
+ SELECT @int{id}, @string{title}, @string{content}, @string{date}, @string{tags}, @string{url}
+ FROM Posts
+ WHERE id = %int{post_id}
+ |sql}
+ record_out]
+ ;;
+
+ let comment =
+ [%rapper
+ get_opt
+ {sql|
+ SELECT @int{id}, @string{content}, @string{date}, @string{tags}, @string{url}
+ FROM Comments
+ WHERE id = %int{id}
+ |sql}
+ record_out]
+ ;;
+
+ let user_comments =
+ [%rapper
+ get_many
+ {sql|
+ SELECT @int{id}, @string{content}, @string{date}, @string{tags}, @string{url}
+ FROM Comments
+ WHERE author = %string{username}
+ |sql}
+ record_out]
+ ;;
+
+ let post_comments =
+ [%rapper
+ get_many
+ {sql|
+ SELECT @int{id}, @string{content}, @string{date}, @string{tags}, @string{url}
+ FROM Comments
+ WHERE post_id = %int{post_id}
+ |sql}
+ record_out]
+ ;;
+
+ let comment_children =
+ [%rapper
+ get_many
+ {sql|
+ SELECT @int{id}, @string{content}, @string{date}, @string{tags}, @string{url}
+ FROM Comments
+ WHERE parent= %int{post_id}
+ |sql}
+ record_out]
+ ;;
+
+ (* Insert queries *)
+ let insert_post =
+ [%rapper
+ execute
+ {sql|
+ INSERT INTO Posts (title, content, date, tags, url)
+ VALUES (%string{title}, %string{content}, %string{date}, %string{tags}, %string{url})
+ |sql}]
+ ;;
+
+ let insert_comment =
+ [%rapper
+ execute
+ {sql|
+ INSERT INTO Comments (content, date, tags, url, post_id, parent, author)
+ VALUES (%string{content}, %string{date}, %string{tags}, %string{url}, %int{post_id}, %int?{parent}, %string{author})
+ |sql}]
+ ;;
+
+ let insert_vote =
+ [%rapper
+ execute
+ {sql|
+ INSERT INTO Votes (post_id, comment_id, user_id, vote_type)
+ VALUES (%int?{post_id}, %int?{comment_id}, %string{user_id}, %string{vote_type})
+ |sql}]
+ ;;
+end
+
+let get_poasts conn = Query.poasts conn
+let get_poast post_id conn = Query.poast ~post_id conn
+
+(* Insert functions *)
+let create_post ~title ~content ~date ~tags ~url conn =
+ Query.insert_post ~title ~content ~date ~tags ~url conn
+
+let create_comment ~content ~date ~tags ~url ~post_id ?parent ~author conn =
+ Query.insert_comment ~content ~date ~tags ~url ~post_id ~parent ~author conn
+
+let create_vote ~user_id ~vote_type ?post_id ?comment_id conn =
+ Query.insert_vote ~post_id ~comment_id ~user_id ~vote_type conn
+
+(* SQLite performance pragmas - to be implemented *)
+let sqlite_pragmas = [
+ "PRAGMA journal_mode = WAL";
+ "PRAGMA foreign_keys = ON";
+ "PRAGMA cache_size = -8000";
+ "PRAGMA temp_store = MEMORY";
+ "PRAGMA synchronous = NORMAL";
+ "PRAGMA mmap_size = 30000000000";
+]
+
+(* Example of how to execute raw SQL with Caqti *)
+let init_connection conn =
+ let module C = (val conn : Rapper_helper.CONNECTION) in
+ (* Create a request: unit input -> unit output using Caqti infix operators *)
+ let pragma_req sql =
+ let open Caqti_request.Infix in
+ let open Caqti_type in
+ (unit ->. unit) sql
+ in
+ (* Execute each pragma *)
+ List.fold_left (fun acc sql ->
+ match acc with
+ | Error e -> Error e
+ | Ok () -> C.exec (pragma_req sql) ()
+ ) (Ok ()) sqlite_pragmas
diff --git a/lib/router.ml b/lib/router.ml
new file mode 100644
index 0000000..013686c
--- /dev/null
+++ b/lib/router.ml
@@ -0,0 +1,84 @@
+(* Import the StdLabels module which provides labeled versions of standard library functions *)
+open StdLabels
+
+(* Import the Routes library for type-safe HTTP routing *)
+open Routes
+
+(* Import Piaf for HTTP types like Method.t *)
+open Piaf
+
+(* Create a custom Map module keyed by HTTP methods (GET, POST, etc.) *)
+(* This allows us to organize routes by their HTTP verb *)
+module R = Map.Make (struct
+ (* The key type is Piaf's Method.t (represents HTTP methods) *)
+ type t = Method.t
+
+ (* Define how to compare two HTTP methods for ordering in the map *)
+ let compare a b =
+ (* Convert methods to strings (e.g., "GET", "POST") *)
+ let a_str = Method.to_string a in
+ let b_str = Method.to_string b in
+ (* Use standard string comparison *)
+ String.compare a_str b_str
+ ;;
+ end)
+
+(* Define all routes in the application *)
+let routes =
+ (* Use fold_left to build up a map of routes *)
+ List.fold_left
+ (* For each (verb, route) pair, add the route to the map under that verb *)
+ ~f:(fun acc (v, r) -> R.add_to_list v r acc) (* Start with an empty map *)
+ ~init:R.empty
+ (* List of (HTTP method, route pattern -> handler) tuples *)
+ [ `GET, (s "posts" /? nil) @--> Handler.get_posts
+ (* `GET - HTTP GET method *)
+ (* s "posts" - matches the literal string "posts" *)
+ (* /? - path concatenation operator *)
+ (* nil - end of path (no more segments) *)
+ (* @--> - binds the route pattern to the handler function *)
+ (* Handler.get_posts - the function that handles this route *)
+ ; `GET, (s "posts" / int /? nil) @--> Handler.get_post
+ (* / int - captures an integer parameter (post ID) *)
+ ; `GET, (s "comments" / int /? nil) @--> Handler.get_comment
+ (* Get a single comment by ID *)
+ ; `GET, (s "users" / str / s "comments" /? nil) @--> Handler.get_user_comments
+ (* / str - captures a string parameter (username) *)
+ ; `GET, (s "posts" / int / s "comments" /? nil) @--> Handler.get_post_comments
+ (* Get all comments for a specific post *)
+ ; `GET, (s "comments" / int / s "children" /? nil) @--> Handler.get_comment_children
+ (* Get child comments (replies) for a comment *)
+ ; `POST, (s "posts" /? nil) @--> Post_handlers.create_post (* Create a new post *)
+ ; `POST, (s "comments" /? nil) @--> Post_handlers.create_comment
+ (* Create a new comment *)
+ ; `POST, (s "votes" /? nil) @--> Post_handlers.create_vote
+ (* Create a new vote *)
+
+ (* bs5 routes *)
+ (* root *)
+ ; `GET, nil @--> Pages.get_root
+ ; `GET, (s "l" /? nil) @--> Pages.get_root
+ ]
+;;
+
+(* Transform the routes map by applying 'one_of' to each list of routes *)
+(* 'one_of' combines multiple routes into a single router that tries each in order *)
+let router = R.map one_of routes
+
+(* Function to match an incoming request against our routes *)
+let match_route verb path =
+ (* Try to find routes for the given HTTP verb in our map *)
+ match R.find_opt verb router with
+ (* If we have routes for this verb, try to match the path *)
+ | Some router ->
+ (* Use the Routes library's match' function to find a matching route *)
+ (match match' router ~target:path with
+ (* Exact match - the path matches completely *)
+ | FullMatch r -> Some r
+ (* Match with trailing slash - e.g., "/posts" matches "/posts/" *)
+ | MatchWithTrailingSlash r -> Some r
+ (* No routes matched the path *)
+ | NoMatch -> None)
+ (* We don't have any routes for this HTTP verb *)
+ | None -> None
+;;
diff --git a/run.sh b/run.sh
new file mode 100644
index 0000000..44d7ac8
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,3 @@
+dune clean
+dune build
+dune exec bin/server.exe
diff --git a/test/dune b/test/dune
new file mode 100644
index 0000000..612ec02
--- /dev/null
+++ b/test/dune
@@ -0,0 +1,2 @@
+(test
+ (name test_bs5))
diff --git a/test/test_bs5.ml b/test/test_bs5.ml
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/test/test_bs5.ml