Skip to main content

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.

common/src/types/todos/todo.ts
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:

common/src/blueprints/todos/create-todo.ts
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:

Stack Root Folder
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:

backend/src/api/endpoints/todos/create-todo.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.

backend/src/api/endpoints/todos/create-todo.ts
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.

backend/src/api/endpoints/todos/create-todo.ts
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.

backend/src/platform/mongo.ts
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:

AuthPrivateType
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.

backend/src/api/endpoints/todos/create-todo.ts
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:

common/src/blueprints/todos/create-todo.ts
...

export type ResponseType = { uuid: string; };

...

Which means that our handler function must return { uuid: string; }. Easy enough:

backend/src/api/endpoints/todos/create-todo.ts
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.

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:

frontend/src/views/todos-view.tsx
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:

frontend/src/views/todos-view.tsx
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".

frontend/src/views/todos-view.tsx
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.

frontend/src/views/todos-view.tsx
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:

frontend/src/views/todos-view.tsx
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:

frontend/src/views/todos-view.tsx
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:

common/src/blueprints/todos/get-my-todos.ts
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:

Stack Root Folder
npx moopsy build-common

Add the Endpoint:

backend/src/api/endpoints/todos/get-my-todos.ts
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 };
});
backend/src/api/index.ts
...

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:

frontend/src/views/todos-view.tsx
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):

frontend/src/views/todos-view.tsx
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:

frontend/src/views/todos-view.tsx
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:

frontend/src/views/todos-view.tsx
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.