I use rescript-struct
for endocing-decoding. It’s still needed to make sure that the data on the backend is valid. Here’s what we have for Next.js api. It’s kind of similar to the trpc.
// ApiHandler.res
type t<'body, 'data> = {
method: Next.Handler.method,
bodyStruct: S.t<'body>,
dataStruct: S.t<'data>,
route: string,
}
let register = (handler, invoke): Next.Handler.t => {
let exceptionResponse = message =>
{
"message": message,
}->(Obj.magic: {"message": string} => Json.t)
async (req, res) => {
if req.method !== handler.method {
res
->Next.Handler.Res.status(405)
->Next.Handler.Res.setHeader("Allow", (handler.method :> string))
->Next.Handler.Res.json(exceptionResponse("Method Not Allowed"))
} else {
switch req.body->S.parseAnyWith(handler.bodyStruct) {
| Ok(body) =>
let data = await invoke(body)
switch data->S.serializeWith(handler.dataStruct) {
| Ok(json) => res->Next.Handler.Res.status(200)->Next.Handler.Res.json(json)
| Error(error) =>
res
->Next.Handler.Res.status(400)
->Next.Handler.Res.json(error->S.Error.toString->exceptionResponse)
}
| Error(error) =>
res
->Next.Handler.Res.status(400)
->Next.Handler.Res.json(error->S.Error.toString->exceptionResponse)
}
}
}
}
let toTask = (handler, body) => {
let path = "/api" ++ handler.route
Task.make((~resolve, ~reject) => {
let ended = ref(false)
switch body->S.serializeToJsonWith(handler.bodyStruct) {
| Error(e) => reject(`Failed to serialize input to JSON. ${e->S.Error.toString}`)
| Ok(bodyJsonString) =>
Fetch.fetch(
path,
{
method: handler.method->(Obj.magic: Next.Handler.method => Fetch.method),
headers: Fetch.Headers.fromObject({
"Content-Type": "application/json",
}),
body: bodyJsonString->Fetch.Body.string,
},
)
->Promise.then(Fetch.Response.json)
->Promise.thenResolve(response =>
if !ended.contents {
switch response->S.parseWith(handler.dataStruct) {
| Ok(data) => resolve(data)
| Error(e) => reject(`Failed to parse data from response. ${e->S.Error.toString}`)
}
}
)
->Promise.catch(exn => {
if !ended.contents {
switch exn {
| Exn.Error(error) => reject(error->Exn.message->Option.getWithDefault("Unknown error"))
| exn => raise(exn)
}
}
Promise.resolve()
})
->ignore
}
Some(() => ended := true)
})
}
module Login = {
type body = {
email: Email.t,
password: Password.t,
}
type ok = {user: User.t}
type error =
| InvalidEmail
| UserNotFound
| UnknownError
type data = result<ok, error>
let make = () => {
method: #POST,
route: "/login",
bodyStruct: S.object(o => {
email: o->S.field("email", Struct.email),
password: o->S.field("password", Struct.password),
}),
dataStruct: S.union([
S.literal(Error(InvalidEmail)),
S.literal(Error(UserNotFound)),
S.literal(Error(UnknownError)),
S.object(o => Ok({
user: o->S.field("user", Struct.user),
})),
]),
}
}
// LoginHandler.res
let handler = ApiHandler.Login.make()->ApiHandler.register(async ({
email,
password
}): ApiHandler.Login.data => {
// Do stuff
Ok({
user: user,
})
}
// Somewhere on the client
let loginHandler = ApiHandler.Login.make()
@react.component
let make = () => {
<button onClick={_ => {
let loginTask = loginHandler->ApiHandler.toTask({
email,
password,
})
loginTask->Task.fork(~onResolve=Console.log,~onReject=Console.log)->ignore
}} />
}