A Pokemon RESTapi ready to serve Pokemon resources!
Node, TypeScript, Express, Prisma, supabase (PostgreSQL), Zod, Docker
Install the project with the following commands
cd apiTest
npm install
To run this project, the following environment variables are needed in the .env file
DATABASE_URL="postgresql://postgres:NyN$$123@[email protected]:5432/postgres"
SERVER_PORT=4000
Run ALL-IN-ONE recomended⭐
npm run dev
This command will execute the dev script, located inside the package.json
file, which has the following 3 concatenated commands:
npx prisma generate
: is responsible for the generation of the Prisma client.npm run data:import
: is used to call thedataImport
function, which dynamically loads the pokemon from the POKEAPI and transforms them to insert them into our Mypokeapi🔥. (see filesrc/ seeder.ts
).npx tsx watch src/index.ts
: this last script is used to start the server, at this time it is ready to receive requests and respond accordingly.
This solution is dockerized, you can clone the image and run it from the docker-hub. To clone the image run the following command
docker pull santiagomanso/rest-api:0.0.1.RELEASE
Once the image has been cloned, run the container with the following command
docker run -d -p 4000:4000 santiagomanso/rest-api:0.0.1.RELEASE
Once this is done, the API will be ready to process requests.🚀
- Full CRUD (
GET
,POST
,PATCH
,DELTE
) - Validations on all endpoints
- Data fetching from external API (Poke API), transformation and insertion of such data into the database🔥.
- M.V.C (model-view-controller) architecture.
GET /mypokeapi/
This endpoint does not require any parameters of any kind.
GET /mypokeapi/id
PARAMETER | TYPE | URL | BODY | MANDATORY | DESCRIPTION |
---|---|---|---|---|---|
id |
int |
YES | NO | mandatory |
unique identifier |
POST /mypokeapi/new
PARAMETER | TYPE | URL | BODY | MANDATORY | DESCRIPTION |
---|---|---|---|---|---|
id |
int |
NO | YES | mandatory |
unique identifier |
name |
string |
NO | YES | mandatory |
name |
weight |
int |
NO | YES | mandatory |
weight |
types |
string[] |
NO | YES | mandatory |
types (see enums) |
PATCH /mypokeapi/update/id
PARAMETER | TYPE | URL | BODY | MANDATORY | DESCRIPTION |
---|---|---|---|---|---|
id |
int |
YES | NO | mandatory |
unique identifier |
name |
string |
NO | YES | NO | name |
weight |
int |
NO | YES | NO | weight |
types |
string[] |
NO | YES | NO | types (see enums) |
DELTE /mypokeapi/delete/id
PARAMETER | TYPE | URL | BODY | MANDATORY | DESCRIPTION |
---|---|---|---|---|---|
id |
int |
YES | NO | mandatory |
unique identifier |
Making a curl to the API
curl localhost:4000/mypokeapi/4
It gives us as a result a JSON object with the following shape
{
"pokemon": {
"id": 4,
"name": "charmander",
"weight": 85,
"types": ["fire"]
}
}
to simplify ♻️ the demonstration of the operation of this API I have included a file "src /api.http" whose purpose is to have preloaded the different requests to our API, for it is enough to click in send request and the result will be painted in a new tab of VSCODE. To be able to make use of this functionality it is necessary to install the following extension in VSCODE: REST CLIENT.
In the example we can see how a pokemon is created, as we can also see how in the request, both the name and the types are not formatted correctly and then when introducing the new pokemon to the database, these fields are correctly formatted.
file src /seeder.ts: It is in charge of acting as a seeder, it contains two functions
- importData: popularize the database hosted in SUPABASE.
- destroyData: perform a DROP (delete) all database resources.
file: utils /fetchAndParseFromPokeAPI.ts. It contains two auxiliary functions that are in charge of doing a fetch()
to the pokeapi, to obtain the 151 (the original pokemons💓) and then to strip them of properties foreign to our interest by calling the second auxiliary function sanitizePokemons()
.
fetchAndParseFromPokeAPI()
: This function is responsible for doing the fetch to the pokeapi api with the following url https://pokeapi.co/api/v2/pokemon?limit=151 (brings us the original 151 pokemons💓), once obtained the pokemons calls the following auxiliary function.sanitizePokemons( pokemonArray: InitialPokemon [ ] )
:This function receives an array of pokemons with the following typing InitialPokemon. the shape of all objects is as follows:
initialPokemon:{
name: string,
url: string
}
all 151 pokemon have this form. However, we are interested in a list with pokemon with the following properties: ID
, NAME
, WEIGHT
, TYPES
(array of characters), so we must make a new FETCH for each pokemon taking advantage that these objects of type (InitialPokemon) have the URL property.
Then the function sanitizePokemons()
creates an array of promises of type SinglePokemon[ ]
, and this array is defined using a map, and the object that returns the map to store inside the promise has the form SinglePokemon [ ]
discarding this way the properties that are not of our interest and only conserving those that are of importance for this api.
Then to resolve all the promises together we use the Promise.all()
method and return a new array called parsedPokemons of type SinglePokemon [ ]
that finally is returned by the first function getAllPokemons()
, and from there it is invoked by the function importData of the file src /seeder.ts
.
file prisma /schema.prisma.
-
client
: this file contains the configuration of the prisma client, the connection string to the database and the models of the above mentioned tables. -
generatorClient
: it is in charge of generating the prisma javascript client (prisma also generates clients for other languages, but we are only interested in JS). -
datasourceDB
: configuration object where the unique database connection string is stored (in this case it is located in the.env
file), and the database provider, in this case postgresql 🐘.
file config /prismaClient.ts: Here you create a new instance of the prisma client and export it to be used in the different controllers of the API, without this file you would create multiple instances for each function of each controller. This way it is created only once and exported in the following way export const prisma = new PrismaClient()
and then it is used wherever it is needed only by importing it and invoking its methods directly: prisma.pokemons.findById() or prisma.pokemons.create().
Zod is a library for declaring and validating schemas in TypeScript
💓. It facilitates the definition and validation of data structures by providing concise syntax and robust validation capabilities.
file: validations /pokemon-schemas.ts: Here we find the schemas that will be used to validate the bodies and parameters of HTTP requests received by our API. As it is made with TypeScript
we can take advantage of the types and interfaces to define the arguments that will receive the functions that validate the schemas, for example:
const patchBodySchema = z.object({
name: z.string().min(1, 'At least one char').optional(),
weight: z.coerce
.number({ invalid_type_error: 'weight must be a number' })
.int()
.min(1)
.optional(),
types: z.array(z.nativeEnum(PokemonEnums)).optional(),
})
This schema is an object that must comply with the following form 🔹:
NAME
: must be aString
with at least 1 character and may be optional.WEIGHT
: must be anINT
(cannot be negative), of at least 1 and may be optional.TYPES
: is an array of characters that come from the ENUMS that are defined intypes /pokemon-interfaces.ts
, if you pass astring
that does not match any of the enums provided it will give an error.
Validation of the schemas: to validate a parameter a function is called that in turn invokes the schema.safeParse
, this is in charge of validating the properties of the object that receives against the properties of the defined schema.
export const validatePatchBody = (input: SinglePokemon) => {
input.name ? (input.name = input.name.trim().toLowerCase()) : ''
if (input.types.length > 0) {
const transformedInput = {
...input,
types: input.types.map((type) => type.toLowerCase().trim()),
}
return patchBodySchema.safeParse(transformedInput)
} else return patchBodySchema.safeParse(input)
}
This function receives an input object, which is of type SinglePokemon
, here we take advantage of the use of TypeScript
to facilitate the work of validating.
If the name property exists, we perform a .trim()
to remove empty spaces at the end and at the beginning and then a .toLoverCase()
to ensure data consistency, since the names are stored in lowercase and without spaces; we do not know how the operator can introduce the values and later it could be of vital importance for a search by name. The same happens when detecting that a type array is passed to the input object, a loop will be performed and it will be transformed to lowercase and whitespaces will be removed at the beginning and end, keeping the other properties of the iterated object with the spread operator.
To ensure data consistency throughout the application, there are different interfaces and TypeScript
types that are applied to different data structures such as objects and/or arrays, these are detailed below.
types /pokemon-interfaces.ts
- interface
InitialPokemon
: object with the following formname:string, url:string
. This interface is used inside the auxiliary function getAllPokemons to type the initial fetch of the 151 pokemon. - interface
SinglePokemon
: it is used in thesanitizePokemons()
function to strip the pokemon of properties that are not of interest. It is also used by zod schemas to type request inputs in POST and PATCH methods, - type
SinglePokemon
[ ]: array type in the form ofSinglePokemon
. This type is used in the auxiliary function getAllPokemons() enum PokemonEnums
: enums object representing the TYPE attribute that is not mutable, these enums are used in the zod schemas to validate the parameters and the body of the HTTPPOST
andPATCH
requests, not passing a TYPE that corresponds to at least one of these enums throws a zod error indicating information pertinent to the field.
The API listens for the route ' /mypokeapi ' and makes available a router called pokemonRouter, which thanks to express is able to facilitate the routing and the switch of the URL, allowing us to organize in a simpler way the routes under the router called pokemonRouter
; it also responds for the following routes.
- Root
'/'
, when querying the root the API will respond with an index.html styled withTailwind CSS
that serves as HomePage. *
: Any Path other than/mypokeapi
or/
the api will respond with a404 - Not Found
.
This express router uses .get()
.post()
.patch()
.delete()
methods and once a request is made to any of these the router calls the class PokemonController🔄️ and makes use of a function declared inside this class, see pokemon-controller.ts.
example: a GET request made on the following endpoint: GET http://localhost:4000/mypokeapi/ will trigger the following router call:
pokemonRouter.get('/', PokemonController.getAll)
getAll: this function declared in PokemonController🔄️ will be in charge of calling Model PokemonModel🔄️ to receive the pokemon as follows:
const pokemons = await PokemonModel.getAll()
The Model is only in charge of interacting with the database by means of Prisma and returning an array of Pokemon to the Controller, it is there where the data flow continues; after validating that the pokemon received by the Model exist, the res
response of the controller will be an array of all the pokemon with which the database is populated at the moment of making this req
request. Author's opinionated note: The VIEW in this case is the JSON object itself, since it is a way to represent the processed information, but it could well be a screen of some app made with React. js, React-Native, Angular, or also a JSON, since it is a way to represent the processed information.
Conclusion: The MVC is an architectural pattern that focuses on separating the responsibility in 3 well defined parts MODEL-VIEW-CONTROLLER, they do not need to know how the other works, they only take care of their own responsibility.
-
Model: it is in charge of accessing the database, updating data. It is the business logic.
-
View: this defines how the application data should be displayed.
-
Controller: acts as an intermediary between the Model and the View; it contains logic that updates the model and/or view in response to inputs from the application's users.
endpoint: bash://localhost:4000/mypokeapi/update/:id
: A req request to this endpoint will cause the express router to call the update function defined inside the PokemonController.ts that receives an id as parameter, because the req is typed with typescript and validated with zod, the first thing to do will be to call the function that validates the id
with the corresponding schema of zod, the function is called validateId()
and receives an object as parameter. Inside this function, Zod validates the ID, converts it to a number and ensures that it is an integer, an INT.
const getByIdSchema = z
.object({
id: z.coerce
.number({ invalid_type_error: 'id must be a number' })
.int({
message: 'id must be positive',
})
.min(1, {
message: 'id must be at least 1',
}),
})
.required()
The safeParse(input)
function returns an object that has one property in case the validation was successful, or two properties in case there is an error. For the correct handling of errors we must ask if the property !success
is false, and only in that moment we will obtain access to the error property, this is an own behavior of TypeScript, remember that zod is constructed in its totality in this language.
export const validateId = (input: { id: number }) => {
return getByIdSchema.safeParse(input)
}
The data flow continues as follows: we already have an id
correctly converted into INT, and we can look for the desired pokemon💓 to update it! Remembering the MVC architecture, the controller does not care how to retrieve our beloved pokemon, the only one who knows how to find it is the MODEL, the PokemonModel. We will ask this model to ship a pokemon where the id matches the id we have stored in the parsedId
object.
const existingPokemon = await PokemonModel.getById(parsedId.data.id)
If the MODEL does not find a pokemon, it will return undefined and therefore the api will return a res
response with status 404 indicating that the pokemon was not found:
if (!existingPokemon) {
return res.status(404).json({
message: 'Pokemon not found',
})
}
Otherwise, we have a pokemon in existence💓, then we will proceed to validate what we have received in the req.body (this is injected by the express midleware in the index.ts file); this req.body
is passed to a validatePatchBody function that will check that the NAME
, WEIGHT
, TYPES
fields are in agreement with the patchBodySchema
which is an object whose properties. Before validating the req.body with zod, we must check if there is a TYPES array inside the req
, in case we do not find the TYPES array (electric, fight, voldaor etc.) we must assign it, since we have it inside our existing pokemon existingPokemon, this is because the zod schema expects the types.
if (!req.body.types) {
req.body.types = existingPokemon.types
}
Now we can validate and once we have the validation ready, it will be stored in an object called parsedBody
. It is at this moment that we can ask the MODEL to update the pokemon with the data coming from the request, with the id located in parsedId and the body in parsedBody.
const updatedPokemon = await PokemonModel.updateById({
id: parsedId.data.id,
input: parsedBody.data,
})
If there is an error when updating the pokemon, we will return a response with code 500 and a message indicating the error.
Otherwise, we are in the situation that the pokemon has been correctly updated and then we will return a code 200, indicating that the req
was correctly processed, and we will also return the updated pokemon.
return res.status(200).json({
updatedPokemon: updatedPokemon,
})
HOW TO TACKLE THE POTENTIAL ID CHANGE: in this update, we do not want to change the ID
of the pokemon, remember that the SinglePokemon object has an id property declared in this interface and it is this interface that we use to type the input that receives the function that validates the patch method schema.
export interface SinglePokemon {
id: number
//...rest of the interface
}
export const validatePatchBody = (input: SinglePokemon)=> {
input.name ? (input.name = input.name.trim().toLowerCase()) : ''
if (input.types.length > 0) {
const transformedInput = {
//...rest of the code
}
Then the question arises, how do we avoid the ID change? With the same zod schema, the patchBodySchema
, if we look closely there is no id
property in that object, then ZOD does not care that you can pass properties like id
, sql
password
, any extra property you pass it, Zod will simply ignore it.
const patchBodySchema = z.object({
// there is no property ID
name: z.string().min(1, 'At least one char').optional(),
weight: z.coerce
.number({ invalid_type_error: 'weight must be a number' })
.int()
.min(1)
.optional(),
types: z.array(z.nativeEnum(PokemonEnums)).optional(),
})