Implementing an Endpoint E2E
In this guide, we'll follow the full flow of implementing a Moopsy Endpoint, registering the handler on the server, and consuming the endpoint on the client from end-to end.
We'll cover some things that are out of scope of MoopsyJS for clarity, we'll make sure to mark these.
In this example, we'll start by creating a endpoint to create a to-do, then another to fetch a list of to-dos.
Assumptions
- We assume you're working with a Moopsy Stack and the Moopsy DevTools
- We assume you already have Authentication setup and implemented, see "Intro to Authentication" guide for more on that
Step 0. Get our Bearings
Let's get started. Assuming the name of your app is todo-app/
, we should open up a terminal and make sure it is in the root folder of the app, which is todo-app
. If you want to make sure you're in the right place, run:
ls
and ensure you see something like:
frontend backend common
Now, for your IDE, make sure you have it opened to the same folder so you can see the same three folders from above. If you're using VSCode, go to File > Open Workspace from File
and open moopsy.code-workspace
to ensure TypeScript behaves correctly.
Let's quickly make sure we understand the structure of a Moopsy Stack.
- The
frontend/
folder contains a CRA React App - The
backend/
folder contains a TypeScript NodeJS app - The
common/
folder contains a collection TypeScript files
The common/
folder has a single folder, src/
, containing two folders called blueprint/
and types/
.
Whenever we run any command with the Moopsy DevTools, the DevTools will run what we call a "build-common", which copies everything inside common/src
to src/
folders of frontend
and backend
. The DevTools will completely overwrite anything defines in src/blueprints
or src/types
.
Step 1. Define the Todo interface
Let's make a folder inside common/src/types
called todos
, and inside that folder (common/src/types/todos
) let's create a file called todo.ts
. Here we'll define an interface for our Todo.
interface Todo {
text: string;
owner: string;
uuid: string;
}
While Moopsy doesn't technically care how you manage types, it is strongly recommended to keep types in common/src/types
so they're shared across the Server & Client reliably.
Step 2. Define the Blueprint
We'll start by defining our Blueprint. You can learn more about Blueprints here, but essentially a blueprint defines the parameters of an endpoint, the response of an endpoint, and a few other pieces of metadata.
Blueprints will always go in common/src/blueprints
. This folder can have sub-folders. When determining where a Blueprint should go, consider the path you'd give the Blueprint in a regular RESt scenario. For example, if the endpoint would be something like https://api.example.com/todos/create-todo
, then make a file called create-todo.ts
inside common/src/blueprints/todos
.
Inside it, we have this structure:
import { type Todo } from "../../types/todos/todo";
export const Endpoint = "todos/create-todo";
export type ParamsType = { text: string; };
export type ResponseType = { uuid: string; };
export const Method = 'POST';
export interface Plug {
params: ParamsType;
response: ResponseType;
method: typeof Method;
endpoint: typeof Endpoint;
}
If you want to understand more about exactly what we're doing here, check out the Blueprints Docs, but basically we say that this endpoint ("todos/create-todo"
) accepts
a paramters object with a single string property, "text"
, and returns an object with a single string property, "uuid"
. The rest of it is just housekeeping to make sure that TypeScript works
properly.
Finally, we need to run:
npx moopsy build-common
This copies the newly created Blueprint across to frontend and backend. The Moopsy DevTools will also automatically parse the ParamsType
you defined, and create a schema which will automatically validate incoming client requests are valid at runtime.
Step 3. Create the Endpoint Handler
Let's open up backend
and head to backend/src/api/endpoints
. Here, we want to match the structure that we created in common
, so since our blueprint in common is common/src/blueprints/todos/create-todo.ts
, we'll make a folder called todos
and inside it a file called create-todo.ts
(at backend/src/api/endpoints/todos/create-todo.ts
).
We'll open it up and get started. We can start by importing our Moopsy Server, usually located at backend/src/platform/moopsy-server.ts
:
import { server } from "backend/src/platform/moopsy-server";
Next, we'll import the blueprint. Anything in common/src
gets copied to backend/src
, so we can import it from backend/src/blueprints/todos/create-todo
. Never import directly from common.
import { server } from "backend/src/platform/moopsy-server";
import * as CreateTodoBP from "../../../blueprints/todos/create-todo";
Notice the naming convention of the import, the endpoint/file name in pascal case with "BP" at the end. Make sure to maintain this format for code clarity.
Now we can register the endpoint and create an empty handler function.
import { server } from "backend/src/platform/moopsy-server";
import * as CreateTodoBP from "../../../blueprints/todos/create-todo";
server.endpoints.register<CreateTodoBP.Plug>(CreateTodoBP, async (params, auth) => {
});
The handler will receive two args, params
which is the parameters the client passed (as mentioned before, Moopsy automatically ensures at runtime that this is indeed in the format of ParamsType
) and
auth
which will be in the format { private: PrivateAuthType, public: AuthSpec.PublicAuthType }
. You can find the full docs for registering an endpoint here.
So now it is pretty straightforward. We just have to save the Todo. Let's create a MongoDB collection, it doesn't matter where you do this but your Moopsy app should already have a mongo file
already (usually at backend/src/platform/mongo.ts
). We'll import the Todo interface we created in Step 1, and create a collection.
import { type Todo } from '../types/todos/todo.ts';
...
export const TodosCollection = mongoDB.collection<Todo>('todos');
...
Back in our endpoint file, we want to import the collection and save the Todo. We also need to make sure we track the owner of the Todo. Lets assume that AuthPrivateType
looks something like this:
export interface AuthPrivateType {
userUUID: string;
}
Let's save our todo! We'll add a quick import to the uuid
package (which comes installed with the Moopsy boilerplate) so we don't need to rely on MongoDB's _id
field.
import { server } from "backend/src/platform/moopsy-server";
import * as CreateTodoBP from "../../../blueprints/todos/create-todo";
import { TodosCollection } from "../../../platform/mongo";
import * as UUID from "uuid";
server.endpoints.register<CreateTodoBP.Plug>(CreateTodoBP, async (params, auth) => {
const newTodoUUID = UUID.v4();
await TodosCollection.insertOne({
text: params.text,
owner: auth.private.userUUID,
uuid: newTodoUUID
});
});
Note, we use auth.private.userUUID
without first checking it. We can do this safely as in our blueprint we did not define isPublic = true
. If the method
is not public, Moopsy will prevent any calls from going through if the user is not authenticated (both on the client and the server). Moopsy' strong typing
makes sure this happens safely, if your Blueprint had isPublic = true
, auth
would automatically be typed as nullish
and TypeScript would require you to validate
it has a value.
We also don't do any validation for params.text
, Moopsy automatically generates JSON Type Schemas from ParamsType
of our blueprint, so we can assume that
params.type
is indeed a string. Note, that we don't validate length, so that could be a 0 character or a 1,000,000 character string.
If you're following along in your IDE, you'll see we still have an error. That's because in our blueprint we said:
...
export type ResponseType = { uuid: string; };
...
Which means that our handler function must return { uuid: string; }
. Easy enough:
import { server } from "backend/src/platform/moopsy-server";
import * as CreateTodoBP from "../../../blueprints/todos/create-todo";
import { TodosCollection } from "../../../platform/mongo";
import * as UUID from "uuid";
server.endpoints.register<CreateTodoBP.Plug>(CreateTodoBP, async (params, auth) => {
const newTodoUUID = UUID.v4();
await TodosCollection.insertOne({
text: params.text,
owner: auth.private.userUUID,
uuid: newTodoUUID
});
return { uuid: newTodoUUID };
});
Our endpoint function is finished! One last thing to do. We need to make sure we import the file so that the server.endpoints.register
file actually gets called. Let's do that from backend/src/api/index.ts
.
...
import "./endpoints/todos/create-todo";
...
Pat yourself on the back! Hopefully that wasn't too hard ^_^.
Step 4. Call the Endpoint
This is a tutorial on Moopsy, not a tutorial on React, routing, etc. So we're gonna assume we have a file called frontend/src/views/todos-view.tsx
. If you're following along, it is up to you to
make sure this file exists and is rendered.
We're also going to assume you have a MoopsyClient
instance at frontend/src/moopsy.ts
, since this is where the boilerplate places it.
Let's make an empty view:
import React from 'react';
const TodosView = React.memo(function TodosView(): JSX.Element {
return (
<div>
</div>
);
})
We can start by importing our Moopsy client and the blueprint:
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
return (
<div>
</div>
);
}
Now we're gonna create a Moopsy Mutation (docs here). Note the naming convetion, the name of the blueprint file in camel case + "mutation".
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP);
return (
<div>
</div>
);
}
We'll setup a ref for the input to obtain the text from, an empty onCreateTodo
handler, and add an input and button in the view.
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP);
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
<button onClick={onCreateTodo}>Create</button>
</div>
);
}
Inside onCreateTodo
, we'll safely extract the value from the input and call the mutation:
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP);
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
if(createTodoTextInputRef.current == null) {
return;
}
const text = createTodoTextInputRef.current.value;
createTodoTextInputRef.current.value = "";
void createTodoMutation.call({
text
});
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
<button onClick={onCreateTodo}>Create</button>
</div>
);
}
And that's it! We can now call the server and save our Todos. You can validate that the server is indeed saving them by adding a console.log
if you wish.
We'll do one more thing to ensure optimal UX, createTodoMutation
will expose a property called isLoading
that we can use to update the UI when the mutation is loading. Let's do that now:
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP);
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
if(createTodoTextInputRef.current == null) {
return;
}
const text = createTodoTextInputRef.current.value;
createTodoTextInputRef.current.value = "";
void createTodoMutation.call({
text
});
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
{createTodoMutation.isLoading ?
<button disabled={true}>Loading...</button>
:
<button onClick={onCreateTodo}>Create</button>
}
</div>
);
}
Step 5. Create a "get-my-todos" endpoint
You know the drill.
Add the Blueprint:
import { type Todo } from "../../types/todos/todo";
export const Endpoint = "todos/get-my-todos";
export type ParamsType = null; // null, since we'll get their user UUID from auth
export type ResponseType = { todos: Todo[]; };
export const Method = 'GET';
export interface Plug {
params: ParamsType;
response: ResponseType;
method: typeof Method;
endpoint: typeof Endpoint;
}
Build Common:
npx moopsy build-common
Add the Endpoint:
import { server } from "backend/src/platform/moopsy-server";
import * as GetMyTodosBP from "../../../blueprints/todos/get-my-todos";
import { TodosCollection } from "../../../platform/mongo";
import * as UUID from "uuid";
server.endpoints.register<GetMyTodosBP.Plug>(GetMyTodosBP, async (params, auth) => {
const todos = await TodosCollection.find({ owner: auth.private.userUUID }).toArray();
return { todos };
});
...
import "./endpoints/todos/create-todo";
import "./endpoints/todos/get-my-todos";
...
Step 6. Call the "get-my-todos" endpoint
Here is where we left of:
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP);
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
if(createTodoTextInputRef.current == null) {
return;
}
const text = createTodoTextInputRef.current.value;
createTodoTextInputRef.current.value = "";
void createTodoMutation.call({
text
});
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
{createTodoMutation.isLoading ?
<button disabled={true}>Loading...</button>
:
<button onClick={onCreateTodo}>Create</button>
}
</div>
);
}
Let's import the new Blueprint and create a query (docs here):
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import * as GetMyTodosBP from "../blueprints/todos/get-my-todos";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const getMyTodosQuery = moopsyClient.useQuery<GetMyTodos.Plug>(GetMyTodos, null); // We specified "null" as ParamsType, so we need to include it
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP);
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
if(createTodoTextInputRef.current == null) {
return;
}
const text = createTodoTextInputRef.current.value;
createTodoTextInputRef.current.value = "";
void createTodoMutation.call({
text
});
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
{createTodoMutation.isLoading ?
<button disabled={true}>Loading...</button>
:
<button onClick={onCreateTodo}>Create</button>
}
</div>
);
}
We're going to make the getMyTodosQuery
a query side effect of createTodoMutation
, learn more about what that does here:
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import * as GetMyTodosBP from "../blueprints/todos/get-my-todos";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const getMyTodosQuery = moopsyClient.useQuery<GetMyTodos.Plug>(GetMyTodos, null);
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP, { querySideEffects: [getMyTodosQuery] });
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
if(createTodoTextInputRef.current == null) {
return;
}
const text = createTodoTextInputRef.current.value;
createTodoTextInputRef.current.value = "";
void createTodoMutation.call({
text
});
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
{createTodoMutation.isLoading ?
<button disabled={true}>Loading...</button>
:
<button onClick={onCreateTodo}>Create</button>
}
</div>
);
}
Now, we just show a loading/error state and display the data:
import React from 'react';
import * as CreateTodoBP from "../blueprints/todos/create-todo";
import * as GetMyTodosBP from "../blueprints/todos/get-my-todos";
import { moopsyClient } from "../moopsy";
const TodosView = React.memo(function TodosView(): JSX.Element {
const getMyTodosQuery = moopsyClient.useQuery<GetMyTodos.Plug>(GetMyTodos, null);
const createTodoMutation = moopsyClient.useMutation<CreateTodoBP.Plug>(CreateTodoBP, { querySideEffects: [getMyTodosQuery] });
const createTodoTextInputRef = React.useRef<HTMLInputElement>(null);
const onCreateTodo = React.useCallback(() => {
if(createTodoTextInputRef.current == null) {
return;
}
const text = createTodoTextInputRef.current.value;
createTodoTextInputRef.current.value = "";
void createTodoMutation.call({
text
});
}, []);
return (
<div>
<input ref={createTodoTextInputRef} placeholder="Create a new todo..."/>
{createTodoMutation.isLoading ?
<button disabled={true}>Loading...</button>
:
<button onClick={onCreateTodo}>Create</button>
}
{getMyTodosQuery.isLoading ?
<div>Loading your todos...</div>
: getMyTodosQuery.isError ?
<div style={{ color: "red" }}>There was an issue loading your todos.</div>
:
<ul>
{getMyTodosQuery.data.todos.map(todo => (
<li key={todo.uuid}>{todo.text}</li>
))}
</ul>
}
</div>
);
}
And that is it! We've built a simple Moopsy app. We can add todos and retrieve them, and when we add a to-do the getMyTodosQuery
data will automatically reflect the new one.
Step 7. Conclusion
You did it! You've create a whole write-path and read-path end to end in Moopsy! We hope you enjoyed.