1use octra_sqlite::{Client, Database, QueryResult, Value};
2use serde_json::{Map, json};
3use std::{
4 env,
5 error::Error,
6 io::{BufRead, BufReader, Write},
7 net::{TcpListener, TcpStream},
8};
9
10type AppResult<T> = Result<T, Box<dyn Error>>;
11
12fn main() -> AppResult<()> {
13 let addr = env::var("OCTRA_SQLITE_API_ADDR").unwrap_or_else(|_| "127.0.0.1:8787".to_string());
14 let database_name = env::var("OCTRA_SQLITE_DATABASE").unwrap_or_else(|_| "remilia".to_string());
15 let client = Client::from_default_config()?;
16 let database = client.database(database_name.clone())?;
17 let listener = TcpListener::bind(&addr)?;
18
19 eprintln!("listening on http://{addr}");
20 eprintln!("try: curl http://{addr}/collections/milady");
21
22 for stream in listener.incoming() {
23 match stream {
24 Ok(stream) => {
25 if let Err(error) = handle_connection(stream, &database, &database_name) {
26 eprintln!("request error: {error}");
27 }
28 }
29 Err(error) => eprintln!("accept error: {error}"),
30 }
31 }
32
33 Ok(())
34}
35
36fn handle_connection(
37 mut stream: TcpStream,
38 database: &Database,
39 database_name: &str,
40) -> AppResult<()> {
41 let mut reader = BufReader::new(stream.try_clone()?);
42 let mut request_line = String::new();
43 reader.read_line(&mut request_line)?;
44
45 let mut parts = request_line.split_whitespace();
46 let method = parts.next().unwrap_or_default();
47 let path = parts.next().unwrap_or_default();
48
49 if method != "GET" {
50 return write_json(
51 &mut stream,
52 "405 Method Not Allowed",
53 &json!({"error":"method_not_allowed"}),
54 );
55 }
56
57 if path == "/" || path == "/health" {
58 return write_json(
59 &mut stream,
60 "200 OK",
61 &json!({"ok":true,"database":database_name}),
62 );
63 }
64
65 let Some(slug) = collection_slug(path) else {
66 return write_json(
67 &mut stream,
68 "404 Not Found",
69 &json!({"error":"not_found","routes":["GET /collections/<opensea_slug>"]}),
70 );
71 };
72
73 let result = query_collection(database, &slug)?;
74 write_json(
75 &mut stream,
76 "200 OK",
77 &json!({
78 "database": database_name,
79 "collection": slug,
80 "row_count": result.row_count,
81 "rows": rows_as_objects(&result),
82 }),
83 )
84}
85
86fn query_collection(database: &Database, slug: &str) -> AppResult<QueryResult> {
87 let sql = format!(
88 "select name, opensea_slug, chain, relationship, launched_month, date_precision \
89 from collection where opensea_slug = {} limit 1;",
90 sql_quote(slug)
91 );
92 Ok(database.query(&sql)?)
93}
94
95fn collection_slug(path: &str) -> Option<String> {
96 let path = path.split('?').next().unwrap_or(path);
97 let slug = path.strip_prefix("/collections/")?;
98 if slug.is_empty() {
99 None
100 } else {
101 Some(slug.to_string())
102 }
103}
104
105fn rows_as_objects(result: &QueryResult) -> Vec<Value> {
106 result
107 .rows
108 .iter()
109 .map(|row| {
110 let mut object = Map::new();
111 for (column, value) in result.columns.iter().zip(row) {
112 object.insert(column.clone(), value.clone());
113 }
114 Value::Object(object)
115 })
116 .collect()
117}
118
119fn sql_quote(value: &str) -> String {
120 format!("'{}'", value.replace('\'', "''"))
121}
122
123fn write_json(stream: &mut TcpStream, status: &str, value: &Value) -> AppResult<()> {
124 let body = serde_json::to_vec_pretty(value)?;
125 write!(
126 stream,
127 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
128 body.len()
129 )?;
130 stream.write_all(&body)?;
131 Ok(())
132}