diff options
author | polwex <polwex@sortug.com> | 2025-06-27 03:28:54 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-06-27 03:28:54 +0700 |
commit | ba350f124bab36766af6c71ba5e3dc17f33fb5ab (patch) | |
tree | 2b242e86cd8c30db058d110c5a4b7864f45f5be3 |
init
-rw-r--r-- | .envrc | 1 | ||||
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | .ocamlformat | 1 | ||||
-rw-r--r-- | CLAUDE.md | 25 | ||||
-rw-r--r-- | NOTES.md | 23 | ||||
-rw-r--r-- | bin/dune | 15 | ||||
-rw-r--r-- | bin/server.ml | 53 | ||||
-rw-r--r-- | bs5.opam | 31 | ||||
-rw-r--r-- | dune-project | 26 | ||||
-rw-r--r-- | flake.lock | 113 | ||||
-rw-r--r-- | flake.nix | 66 | ||||
-rw-r--r-- | init.sql | 40 | ||||
-rw-r--r-- | lib/dune | 12 | ||||
-rw-r--r-- | lib/handler.ml | 162 | ||||
-rw-r--r-- | lib/pages.ml | 33 | ||||
-rw-r--r-- | lib/post_handlers.ml | 100 | ||||
-rw-r--r-- | lib/query.ml | 159 | ||||
-rw-r--r-- | lib/router.ml | 84 | ||||
-rw-r--r-- | run.sh | 3 | ||||
-rw-r--r-- | test/dune | 2 | ||||
-rw-r--r-- | test/test_bs5.ml | 0 |
21 files changed, 953 insertions, 0 deletions
@@ -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 +;; @@ -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 |