tapi/
endpoints.rs

1use futures_util::StreamExt;
2use itertools::Itertools;
3
4use crate::{
5    targets::{js, ts},
6    transitive_closure, DynTapi, Tapi,
7};
8
9#[derive(Debug)]
10pub enum Method {
11    Get,
12    Post,
13    Put,
14    Delete,
15}
16impl Method {
17    pub fn from_str(s: &str) -> Option<Self> {
18        match s {
19            "GET" => Some(Self::Get),
20            "POST" => Some(Self::Post),
21            "PUT" => Some(Self::Put),
22            "DELETE" => Some(Self::Delete),
23            _ => None,
24        }
25    }
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            Self::Get => "GET",
29            Self::Post => "POST",
30            Self::Put => "PUT",
31            Self::Delete => "DELETE",
32        }
33    }
34}
35
36#[derive(Debug, Clone, Copy)]
37pub enum RequestStructureBody {
38    Query(DynTapi),
39    Json(DynTapi),
40    PlainText,
41}
42#[derive(Debug)]
43pub struct RequestStructure {
44    pub path: Option<DynTapi>,
45    pub method: Method,
46    pub body: Option<RequestStructureBody>,
47}
48
49impl RequestStructure {
50    pub fn new(method: Method) -> Self {
51        Self {
52            path: None,
53            method,
54            body: None,
55        }
56    }
57    pub fn merge_with(&mut self, req: RequestTapi) {
58        match req {
59            RequestTapi::Path(ty) => {
60                self.path = Some(ty);
61            }
62            RequestTapi::Query(ty) => {
63                self.body = Some(RequestStructureBody::Query(ty));
64            }
65            RequestTapi::Json(ty) => {
66                self.body = Some(RequestStructureBody::Json(ty));
67            }
68            RequestTapi::None => {}
69        }
70    }
71}
72pub trait Endpoint<AppState> {
73    fn path(&self) -> &'static str;
74    fn method(&self) -> Method;
75    fn bind_to(&self, router: axum::Router<AppState>) -> axum::Router<AppState>;
76    fn body(&self) -> RequestStructure;
77    fn res(&self) -> ResponseTapi;
78    fn tys(&self) -> Vec<DynTapi> {
79        let mut tys = Vec::new();
80        if let Some(path) = self.body().path {
81            tys.push(path);
82        }
83        if let Some(body) = self.body().body {
84            match body {
85                RequestStructureBody::Query(ty) => {
86                    tys.push(ty);
87                }
88                RequestStructureBody::Json(ty) => {
89                    tys.push(ty);
90                }
91                RequestStructureBody::PlainText => {}
92            }
93        }
94        tys.push(self.res().ty());
95        tys
96    }
97    /// Generate a TypeScript client for this endpoint.
98    ///
99    /// The generated client will look something like this:
100    /// ```ignore
101    /// export const api = {
102    ///     index: request<{}, string>("none", "GET", "/", "text"),
103    ///     api: request<Person, string>("json", "GET", "/api", "json"),
104    ///     api2AB: request<{}, string>("none", "GET", "/api2/:a/:b", "text"),
105    ///     wow: sse<Msg>("/wow", "json"),
106    ///     cool: request<Record<string, string>, Msg>("json", "GET", "/cool", "json"),
107    /// };
108    /// ```
109    fn ts_client(&self) -> String {
110        use std::fmt::Write;
111        let mut s = String::new();
112        match (self.body(), self.res()) {
113            (
114                RequestStructure {
115                    body: None, path, ..
116                },
117                ResponseTapi::Sse(ty),
118            ) => {
119                let mut params = Vec::new();
120                let final_path = self
121                    .path()
122                    .split('/')
123                    .filter(|p| !p.is_empty())
124                    .map(|p| {
125                        if let Some(name) = p.strip_prefix(':') {
126                            params.push(name);
127                            format!("/${{{name}}}")
128                        } else {
129                            format!("/{p}")
130                        }
131                    })
132                    .join("");
133                let final_path = format!("`{final_path}`");
134                if let Some(path_param) = path {
135                    write!(
136                        s,
137                        "sse<[{}], {}>(({}) => {final_path}, \"json\")",
138                        ts::full_ty_name(path_param),
139                        ts::full_ty_name(ty),
140                        params.iter().format(", "),
141                    )
142                    .unwrap();
143                } else {
144                    // TODO: handle non-json responses
145                    write!(
146                        s,
147                        "sse<[{}], {}>(({}) => {final_path}, \"json\")",
148                        "",
149                        ts::full_ty_name(ty),
150                        params.iter().format(", "),
151                    )
152                    .unwrap();
153                }
154            }
155            (RequestStructure { body, .. }, res) => {
156                write!(
157                    s,
158                    "request<{}, {}>({:?}, {:?}, {:?}, {:?})",
159                    match body {
160                        Some(RequestStructureBody::Query(ty)) => ts::full_ty_name(ty),
161                        Some(RequestStructureBody::Json(ty)) => ts::full_ty_name(ty),
162                        // TODO: is this right?
163                        Some(RequestStructureBody::PlainText) =>
164                            "Record<string, never>".to_string(),
165                        None => "Record<string, never>".to_string(),
166                    },
167                    ts::full_ty_name(res.ty()),
168                    match body {
169                        Some(RequestStructureBody::Query(_)) => "query",
170                        Some(RequestStructureBody::Json(_)) => "json",
171                        Some(RequestStructureBody::PlainText) => "none",
172                        None => "none",
173                    },
174                    self.method().as_str(),
175                    self.path(),
176                    match res {
177                        ResponseTapi::PlainText => "text",
178                        ResponseTapi::Bytes => "bytes",
179                        ResponseTapi::Json(_) => "json",
180                        ResponseTapi::Html => "html",
181                        ResponseTapi::Sse(_) => "sse",
182                        ResponseTapi::None => "none",
183                    }
184                )
185                .unwrap();
186            }
187        }
188        s
189    }
190    fn js_client(&self) -> String {
191        use std::fmt::Write;
192        let mut s = String::new();
193        match (self.body(), self.res()) {
194            (
195                RequestStructure {
196                    body: None, path, ..
197                },
198                ResponseTapi::Sse(ty),
199            ) => {
200                let mut params = Vec::new();
201                let final_path = self
202                    .path()
203                    .split('/')
204                    .filter(|p| !p.is_empty())
205                    .map(|p| {
206                        if let Some(name) = p.strip_prefix(':') {
207                            params.push(name);
208                            format!("/${{{name}}}")
209                        } else {
210                            format!("/{p}")
211                        }
212                    })
213                    .join("");
214                let final_path = format!("`{final_path}`");
215                if let Some(path_param) = path {
216                    write!(
217                        s,
218                        "/** @type {{ReturnType<typeof sse<[{}], {}>>}} */ (\n    sse(({}) => {final_path}, \"json\")\n  )",
219                        ts::full_ty_name(path_param),
220                        ts::full_ty_name(ty),
221                        params.iter().format(", "),
222                    )
223                    .unwrap();
224                } else {
225                    // TODO: handle non-json responses
226                    write!(
227                        s,
228                        "/** @type {{ReturnType<typeof sse<[{}], {}>>}} */ (\n    sse(({}) => {final_path}, \"json\")\n  )",
229                        "",
230                        ts::full_ty_name(ty),
231                        params.iter().format(", "),
232                    )
233                    .unwrap();
234                }
235            }
236            (RequestStructure { body, .. }, res) => {
237                write!(
238                    s,
239                    "/** @type {{ReturnType<typeof request<{}, {}>>}} */ (\n    request({:?}, {:?}, {:?}, {:?})\n  )",
240                    match body {
241                        Some(RequestStructureBody::Query(ty)) => ts::full_ty_name(ty),
242                        Some(RequestStructureBody::Json(ty)) => ts::full_ty_name(ty),
243                        // TODO: is this right?
244                        Some(RequestStructureBody::PlainText) =>
245                            "Record<string, never>".to_string(),
246                        None => "Record<string, never>".to_string(),
247                    },
248                    ts::full_ty_name(res.ty()),
249                    match body {
250                        Some(RequestStructureBody::Query(_)) => "query",
251                        Some(RequestStructureBody::Json(_)) => "json",
252                        Some(RequestStructureBody::PlainText) => "none",
253                        None => "none",
254                    },
255                    self.method().as_str(),
256                    self.path(),
257                    match res {
258                        ResponseTapi::PlainText => "text",
259                        ResponseTapi::Bytes => "bytes",
260                        ResponseTapi::Json(_) => "json",
261                        ResponseTapi::Html => "html",
262                        ResponseTapi::Sse(_) => "sse",
263                        ResponseTapi::None => "none",
264                    }
265                )
266                .unwrap();
267            }
268        }
269        s
270    }
271}
272impl<'a, AppState, T> Endpoint<AppState> for &'a T
273where
274    T: Endpoint<AppState>,
275{
276    fn path(&self) -> &'static str {
277        (*self).path()
278    }
279    fn method(&self) -> Method {
280        (*self).method()
281    }
282    fn bind_to(&self, router: axum::Router<AppState>) -> axum::Router<AppState> {
283        (*self).bind_to(router)
284    }
285    fn body(&self) -> RequestStructure {
286        (*self).body()
287    }
288    fn res(&self) -> ResponseTapi {
289        (*self).res()
290    }
291}
292
293pub struct Endpoints<'a, AppState> {
294    endpoints: Vec<&'a dyn Endpoint<AppState>>,
295    extra_tys: Vec<DynTapi>,
296}
297impl<'a, AppState> Endpoints<'a, AppState> {
298    pub fn new(endpoints: impl IntoIterator<Item = &'a dyn Endpoint<AppState>>) -> Self {
299        Self {
300            endpoints: endpoints.into_iter().collect(),
301            extra_tys: Vec::new(),
302        }
303    }
304    pub fn with_ty<T: Tapi + 'static>(mut self) -> Self {
305        self.extra_tys.push(T::boxed());
306        self
307    }
308    pub fn tys(&self) -> Vec<DynTapi> {
309        let mut tys = self.extra_tys.clone();
310        for endpoint in &self.endpoints {
311            tys.extend(endpoint.tys());
312        }
313        tys.sort_by_key(|t| t.id());
314        tys.dedup_by_key(|t| t.id());
315        transitive_closure(tys)
316    }
317    pub fn ts_client(&self) -> String {
318        let mut s = ts::builder().types(self.tys());
319
320        s.push_str("export const api = {\n");
321        for endpoint in &self.endpoints {
322            let name = heck::AsLowerCamelCase(endpoint.path()).to_string();
323            let name = if name.is_empty() { "index" } else { &name };
324            s.push_str(&format!("    {name}: {},\n", endpoint.ts_client()));
325        }
326        s.push_str("};\n");
327        s
328    }
329    pub fn js_client(&self) -> String {
330        let mut s = js::builder().types(self.tys());
331
332        s.push_str("export const api = {\n");
333        for endpoint in &self.endpoints {
334            let name = heck::AsLowerCamelCase(endpoint.path()).to_string();
335            let name = if name.is_empty() { "index" } else { &name };
336            s.push_str(&format!("  {name}: {},\n", endpoint.js_client()));
337        }
338        s.push_str("};\n");
339        s
340    }
341}
342impl<'a, AppState> IntoIterator for Endpoints<'a, AppState> {
343    type Item = &'a dyn Endpoint<AppState>;
344    type IntoIter = std::vec::IntoIter<Self::Item>;
345    fn into_iter(self) -> Self::IntoIter {
346        self.endpoints.into_iter()
347    }
348}
349impl<'s, 'a, AppState> IntoIterator for &'s Endpoints<'a, AppState> {
350    type Item = &'a dyn Endpoint<AppState>;
351    type IntoIter = std::iter::Copied<std::slice::Iter<'s, &'a dyn Endpoint<AppState>>>;
352    fn into_iter(self) -> Self::IntoIter {
353        self.endpoints.iter().copied()
354    }
355}
356
357pub trait RouterExt<AppState: 'static> {
358    fn tapi<E: Endpoint<AppState> + ?Sized>(self, endpoint: &E) -> Self;
359    fn tapis<'a>(self, endpoints: impl IntoIterator<Item = &'a dyn Endpoint<AppState>>) -> Self
360    where
361        Self: Sized,
362    {
363        endpoints.into_iter().fold(self, Self::tapi)
364    }
365}
366
367impl<AppState: 'static> RouterExt<AppState> for axum::Router<AppState> {
368    fn tapi<E: Endpoint<AppState> + ?Sized>(self, endpoint: &E) -> Self {
369        E::bind_to(endpoint, self)
370    }
371}
372
373pub struct Sse<T, E = axum::BoxError>(futures_util::stream::BoxStream<'static, Result<T, E>>);
374impl<T, E> Sse<T, E> {
375    pub fn new<S>(stream: S) -> Self
376    where
377        S: futures_util::Stream<Item = Result<T, E>> + Send + 'static,
378    {
379        Self(stream.boxed())
380    }
381}
382impl<T> axum::response::IntoResponse for Sse<T>
383where
384    T: serde::Serialize + 'static,
385{
386    fn into_response(self) -> axum::response::Response {
387        let stream = self
388            .0
389            .map(|s| -> Result<axum::response::sse::Event, axum::BoxError> {
390                let s = serde_json::to_string(&s?)?;
391                Ok(axum::response::sse::Event::default().data(s))
392            });
393        axum::response::sse::Sse::new(stream).into_response()
394    }
395}
396
397#[derive(Debug)]
398pub enum RequestTapi {
399    Path(DynTapi),
400    Query(DynTapi),
401    Json(DynTapi),
402    None,
403}
404pub trait RequestTapiExtractor {
405    fn extract_request() -> RequestTapi;
406}
407impl RequestTapiExtractor for () {
408    fn extract_request() -> RequestTapi {
409        RequestTapi::None
410    }
411}
412impl<T: Tapi + 'static> RequestTapiExtractor for axum::extract::Path<T> {
413    fn extract_request() -> RequestTapi {
414        RequestTapi::Path(<T as Tapi>::boxed())
415    }
416}
417impl<T: Tapi + 'static> RequestTapiExtractor for axum::extract::Query<T> {
418    fn extract_request() -> RequestTapi {
419        RequestTapi::Query(<T as Tapi>::boxed())
420    }
421}
422impl<T: Tapi + 'static> RequestTapiExtractor for axum::Json<T> {
423    fn extract_request() -> RequestTapi {
424        RequestTapi::Json(<T as Tapi>::boxed())
425    }
426}
427impl<T> RequestTapiExtractor for axum::extract::State<T> {
428    fn extract_request() -> RequestTapi {
429        RequestTapi::None
430    }
431}
432
433#[derive(Debug, Clone, Copy)]
434pub enum ResponseTapi {
435    // `text/plain; charset=utf-8`
436    PlainText,
437    // `application/octet-stream`
438    Bytes,
439    // `application/json`
440    Json(DynTapi),
441    // `text/html`
442    Html,
443    // `text/event-stream`
444    Sse(DynTapi),
445    None,
446}
447pub trait ResponseTapiExtractor {
448    fn extract_response() -> ResponseTapi;
449}
450impl ResponseTapiExtractor for () {
451    fn extract_response() -> ResponseTapi {
452        ResponseTapi::None
453    }
454}
455impl ResponseTapiExtractor for String {
456    fn extract_response() -> ResponseTapi {
457        ResponseTapi::PlainText
458    }
459}
460impl ResponseTapiExtractor for Vec<u8> {
461    fn extract_response() -> ResponseTapi {
462        ResponseTapi::Bytes
463    }
464}
465impl<T: Tapi + 'static> ResponseTapiExtractor for axum::Json<T> {
466    fn extract_response() -> ResponseTapi {
467        ResponseTapi::Json(<T as Tapi>::boxed())
468    }
469}
470impl<T: Tapi + 'static> ResponseTapiExtractor for axum::response::Html<T> {
471    fn extract_response() -> ResponseTapi {
472        ResponseTapi::Html
473    }
474}
475impl<T: ResponseTapiExtractor, E> ResponseTapiExtractor for Result<T, E> {
476    fn extract_response() -> ResponseTapi {
477        T::extract_response()
478    }
479}
480impl<T: Tapi + 'static> ResponseTapiExtractor for Sse<T> {
481    fn extract_response() -> ResponseTapi {
482        ResponseTapi::Sse(<T as Tapi>::boxed())
483    }
484}
485
486impl RequestTapi {
487    pub fn ty(self) -> DynTapi {
488        match self {
489            Self::Path(ty) => ty,
490            Self::Query(ty) => ty,
491            Self::Json(ty) => ty,
492            Self::None => <() as Tapi>::boxed(),
493        }
494    }
495}
496impl ResponseTapi {
497    pub fn ty(self) -> DynTapi {
498        match self {
499            Self::PlainText => <String as Tapi>::boxed(),
500            Self::Bytes => <Vec<u8> as Tapi>::boxed(),
501            Self::Json(ty) => ty,
502            Self::Html => <String as Tapi>::boxed(),
503            Self::Sse(ty) => ty,
504            Self::None => <() as Tapi>::boxed(),
505        }
506    }
507}
508
509// NOTE: This is a WIP implementation of the endpoint trait that does not require any special impl
510
511// trait Endpointish {}
512
513// impl<
514//         A: tapi::endpoints::RequestTapiExtractor,
515//         R: tapi::endpoints::ResponseTapiExtractor,
516//         RF: std::future::Future<Output = R>,
517//     > Endpointish for fn(A) -> RF
518// {
519// }
520
521// impl<
522//         A: tapi::endpoints::RequestTapiExtractor,
523//         B: tapi::endpoints::RequestTapiExtractor,
524//         R: tapi::endpoints::ResponseTapiExtractor,
525//         RF: std::future::Future<Output = R>,
526//     > Endpointish for fn(A, B) -> RF
527// {
528// }
529
530// fn thing_req<E: tapi::endpoints::RequestTapiExtractor>() {}
531// fn thing_res<E: tapi::endpoints::ResponseTapiExtractor>() {}
532// fn thing(e: &dyn Endpointish) {}
533
534// fn thingy() {
535//     thing_req::<State<AppState>>();
536//     thing_req::<Json<ce_shell::Input>>();
537//     thing_res::<Json<Option<AnalysisExecution>>>();
538//     thing(&(exec_analysis as fn(_, _) -> _));
539// }