Reescrevendo a StarWars API em Deno
Rodolpho Alves
Posted on June 23, 2020
Créditos da capa: Dimitrij Agal
👉 TL;DR;
Reescrevi a API do https://swapi.dev utilizando Deno e Svelte. Disponível para testes através do https://swapi-deno.azurewebsites.net/ e do DockerHub
O código da API + Frontend está disponível no GitHub.
rodolphocastro / deno-swapi
A StarWars API written with Deno and powered by Oak and Svelte!
💭 Inspiração
Quem nunca se inspirou em um projeto existente para auxiliar no aprendizado de uma nova linguagem de programação ou Tecnologia?
Eu sou culpado de fazer isso. Direto. Chega a dar vergonha de volta e meia olhar meu GitHub e ver tantos projetos que começo e acabo pausando só pra dar uma olhadinha em alguma outra tecnologia que me chamou a atenção 😅
O projeto da vez, inspirado pelas primeiras versões "production ready" do Deno, foi a reescrita da Star Wars API. Faz tempos que utilizo a Swapi para testar Apps (Web e Mobile) que precisam fazer chamadas a APIs REST e sempre me "frustou" um pouco ela não ter sido atualizada com os dados da trilogia mais recente!
Nota: Não que a versão v0.2.0 deste projeto esteja com os dados recentes! 😂
🦕 Deno
Para aqueles que não estão acompanhando o mundo "Node":
Deno é um runtime simples, moderno e seguro para a execução de Type/Javascript, utilizando a engine V8 e desenvolvido através do RUST
(Fonte: https://deno.land/, traduzido pelo autor)
Em suma a ideia do Deno é pegar todo o aprendizado da comunidade com o NodeJS manter o que é bom e refinar o que "precisa" ser refinado.
Em minha opinião algumas grandes vantagens do Deno são:
- Suporte nativo a Typescript
- Versionamento integrado ao Git (nada de
packages.json
ou o inferno donode_modules
) - Possui um conjunto "nativo" de bibliotecas suportadas
std
(o que me lembra bastante o .NET)
Caso esteja lendo no futuro: Lembre-se que este post foi escrito com base na versão v1.1.1 do Deno! Então algumas coisas ainda eram novas!
Escolhendo nossas dependências
Antes de começar a bater código comecei olhando as bibliotecas que já existiam no ecossistema Deno para fazer duas tarefas primordias de uma API REST:
- Servir o conteúdo através dos endpoints
- Armazenar, de alguma maneira, o conteúdo.
Começando por servir o conteúdo vi que o Deno possui vários "sabores" de frameworks para APIs REST, alguns são:
No momento em que comecei o projeto o que me pareceu mais tentador foi o Oak, especialmente por ser diferente do padrão dotNet Core ao qual estou acostumado 😅.
Em seguida precisava de uma maneira de armazenar o conteúdo de maneira prática. Já existem várias bibliotecas para conectar com bancos SQL e NoSQL, porém para manter o menor footprint possível para a API pensei em embarcar os dados junto à API.
Olhei nas bibliotecas std
e econtrei o que precisava para lidar com arquivos: a biblioteca fs.
Sugestões para o ambiente de desenvolvimento
Antes de passarmos ao código, algumas sugestões:
Para desenvolver a API e o Portal utilizei o Visual Studio Code com as extensions:
Lendo os dados de arquivos json
Comecei modelando os dados disponíveis na API Original e os transcrevendo para seis interfaces diferentes:
Film
Person
Planet
Specie
Starship
Vehicle
Utilizando o Insomnia acessei os endpoints da API Original e extrai os dados disponíveis publicamente, higienizando alguns dados (como os "ids") e removendo o envelope.
Desta maneira construi alguns arquivos .json
no seguinte formato, para armazenar os dados junto à API:
{
"data": [
{ ... }
]
}
Com este formato estabelecido abstrai criei um arquivo para cada um dos models
e escrevi algumas interfaces e classes para consolidar a lógica de carregar os dados a partir de arquivos e disponibilizados em um Array
de seu devido tipo T
:
// As of v0.55.0 this module requires the --unstable flag to be used
import * as fs from "https://deno.land/std@v0.55.0/fs/mod.ts";
/**
* Describes the expected structure for a .json storage.
*/
interface JsonStorable<T> {
data?: T[];
}
/**
* Loads data and deserializes data from a json file.
* @param dataDir directory containing the file
* @param filename filename containing the data, serialized
*/
async function loadDataFromFiles<T>(
dataDir: string,
filename: string,
): Promise<T[]> {
await fs.ensureDir(dataDir);
const result = await fs.readJson(dataDir + "/" + filename) as JsonStorable<T>;
return result.data ?? [];
}
Para permitir, eventualmente, a abstração para outra fonte de dados também criei uma estrutura (baseada bem vagamente no Vuex) para armazenar estes dados na aplicação, como um singleton:
export interface IState<T> {
list(): T[];
}
export class ModelState<T> implements IState<T> {
constructor(
private readonly values: T[] = [],
) {}
list(): T[] {
return this.values;
}
}
Finalmente, para casar tudo, criei algums factory methods
para gerar meus States
com base nos .json
:
/**
* Creates and seeds a ModelState for Films.
* @param dataDir directory holding the json file, defaults to ./data
* @param filmFile json file containing films, defaults to films.json
*/
export async function createFilmStateAsync(
dataDir: string = "./data",
filmFile: string = "films.json",
): Promise<IState<Film>> {
const films = await loadDataFromFiles<Film>(dataDir, filmFile);
return new ModelState<Film>(films);
}
/**
* Creates and seeds a ModelState for Species.
* @param dataDir directory holding the json file, defaults to ./data
* @param speciesFile json file containing species, defautls to species.json
*/
export async function createSpecieStateAsync(
dataDir: string = "./data",
speciesFile: string = "species.json",
): Promise<IState<Specie>> {
const species = await loadDataFromFiles<Specie>(dataDir, speciesFile);
return new ModelState<Specie>(species);
}
/**
* Creates and seeds a ModelState for Vehicles.
* @param dataDir directory holding the json file, defaults to ./data
* @param vehiclesFile json file containing species, defautls to vehicles.json
*/
export async function createVehicleStateAsync(
dataDir: string = "./data",
vehiclesFile: string = "vehicles.json",
): Promise<IState<Vehicle>> {
const vehicles = await loadDataFromFiles<Vehicle>(dataDir, vehiclesFile);
return new ModelState<Vehicle>(vehicles);
}
/**
* Creates and seeds a ModelState for Starships.
* @param dataDir directory holding the json file, defaults to ./data
* @param starshipsFile json file containing starships, defaults to startships.json
*/
export async function createStarshipStateAsync(
dataDir: string = "./data",
starshipsFile: string = "starships.json",
): Promise<IState<Starship>> {
const starships = await loadDataFromFiles<Starship>(dataDir, starshipsFile);
return new ModelState<Starship>(starships);
}
/**
* Creates and seeds a ModelState for Planets.
* @param dataDir directory holding the json file, defaults to ./data
* @param planetsFile json file containing planets, defaults to planets.json
*/
export async function createPlanetsStateAsync(
dataDir: string = "./data",
planetsFile: string = "planets.json",
): Promise<IState<Planet>> {
const planets = await loadDataFromFiles<Planet>(dataDir, planetsFile);
return new ModelState<Planet>(planets);
}
/**
* Creates and Seeds a ModelState for People.
* @param dataDir directory holding the json file, defaults to ./data
* @param peopleFile json file containing people, defaults to people.json
*/
export async function createPeopleStateAsync(
dataDir: string = "./data",
peopleFile: string = "people.json",
): Promise<IState<Person>> {
const people = await loadDataFromFiles<Person>(dataDir, peopleFile);
return new ModelState<Person>(people);
}
Com tudo isso pronto, vamos aos endpoints!
Servindo os dados através de endpoints com o Oak
A premissa do Oak é que temos uma Application
e podemos adicionar diversos Middlewares
a ela. Desta maneira a própria camada do Oak irá gerar, para nós, as chamadas necessárias para fazer o Http-Server
do Deno
trabalhar conforme esperamos!
Para os fins da API vamos utilizar o RouterMiddleware
. Este middleware nos permite especificar functions
para lidar com os verbos http em rotas específicas.
Por exemplo, para os endpoints de listar e obter um específico a implementação com o Oak fica assim:
// Criando a Application do Oak
const app = new Application();
// Assuma que o filmsState já está criado
const filmsRouter = new Router({ prefix: "/api/films" });
filmsRouter
// Indicando que este router deverá escutar na rota GET /
.get("/", ({ response }) => {
response.body = filmsState.list();
response.status = Status.OK;
})
// E na rota GET /:id
.get("/:id", ({ response, params }) => {
const { id } = params;
const result = filmsState.list().filter((f) =>
f.url === parseInt(id as string)
)[0];
if (result) {
response.status = Status.OK;
response.body = result;
return;
}
response.status = Status.NotFound;
});
// Adicionando nossos middlewares à Aplicação
app.use(
...[
filmsRouter.routes()
]
);
// e, finalmente, rodando nossa aplicação
await app.listen({ port: 8000 });
O resto dos endpoints seguem todos o mesmo padrão, salvo o endpoint que apresenta os arquivos do nosso frontend!
🤖 Svelte
Após terminar os 6 endpoints originais comecei a pensar como seria interessante ter um SwaggerUI ou alguma interface documentando a API. Porém, no momento, não existem bibliotecas para gerar a OpenApi spec a partir dos endpoints implementados.
Ou seja, não temos algo como o Swagger Plugin do NestJS ou o Swashbuckle do .NET Core.
Então, para aproveitar o embalo, decidi sair da zona de conforto de Vue + TS e dar a cara a tapa para testar outro framework Javascript que muitas pessoas estão adotando: Svelte
A ideia do Svelte é similar à do React e do Vue: Um único arquivo (chamado de component) agrega a lógica, o estilo visual e a estrutura para exibição.. Com a mesma "pegada" do Vue o Svelte é reativo por padrão. (Com algumas diferenças!)
Vindo do Vue as principais diferenças que senti ao usar o Svelte foram:
- Menos "verboso" para declarar componentes
- Não depender de um CLI para um kickstart da configuração
- Utilização de 'blocos' para condicionais e loops, ao invés de atributos no HTML
No geral eu gostei, bastante, da breve experiência com o Svelte e pretendo utiliza-lo mais no futuro. Admito que fiquei tentado a ir atrás de como utilizar Typescript junto ao Svelte mas como a ideia era implementar logo, deixei de lado!
Components do Svelte
A sintaxe de components do Svelte é a seguinte:
<script>
// Código javascript
</script>
<style>
# CSS do component
</style>
<!-- Corpo HTML -->
O principal component do Frontend é o "Browse" genérico. A ideia aqui é que como a estrutura é sempre a mesma (afinal, não estou fazendo um Portal, apenas exibindo possíveis retornos!) um único component, configurável, dá conta de exibir o que é necessário!
Os elementos que notei que repetiam entre cada exibição de dados eram: O endpoint em si, o nome do endpoint, um emoji para exibir no heading
e quais propriedades do elemento deviam ser exibidas na lista interna.
O component resultante dessa parametrização é:
<script>
export let endpointName = "Generic Endpoint";
export let endpointEmoji = "❓";
export let displayProperties = ["url", "name"];
export let endpoint;
const endpointPromise = fetchData();
let showJson = false;
let showList = false;
async function fetchData() {
const response = await fetch(endpoint);
return response.json();
}
function toggleList() {
showList = !showList;
}
function toggleJson() {
showJson = !showJson;
}
</script>
<section container>
<h3 id="{endpointName}">{endpointEmoji} {endpointName}</h3>
<p>
The {endpointName} endpoint is served on the route
<code>{endpoint}</code>
</p>
{#await endpointPromise}
<p>Please wait, loading data...</p>
{:then dataResult}
<p>
There are {dataResult.length} objects of type {endpointName} on the API
</p>
<hr />
<button on:click={toggleJson}>
{showJson ? 'Hide Json' : 'Show Json'}
</button>
<button on:click={toggleList}>
{showList ? 'Hide list' : 'Show list'}
</button>
{#if showJson}
<h4>JSON</h4>
<p>A {endpointName}'s JSON looks like this:</p>
<pre>
<code>{JSON.stringify(dataResult[0], null, '\t')}</code>
</pre>
{/if}
{#if showList}
<h4>Result from the API</h4>
<ul>
{#each dataResult as data}
<li>{data[displayProperties[0]]}: {data[displayProperties[1]]}</li>
{/each}
</ul>
{/if}
{:catch _}
<p>
Ops, something went wrong while fetching data! Please refresh the page
</p>
{/await}
</section>
A cara final do portal ficou assim:
Servindo nossa SPA através do Oak
A última alteração necessária foi adicionar um novo Middleware
ao Oak, apontando ao servidor que os arquivos do subdiretório ./portal/public
deviam ser publicados na rota /
do servidor!
O código resultante é:
// Criando o middleware
const servePortal: Middleware = async ctx => {
await send(ctx, ctx.request.url.pathname, {
root: Deno.cwd()+'/portal/public',
index: 'index.html'
})
};
// Indicando que ele deve ser utilizando, junto aos outros
app.use(
...[
filmsRouter.routes(),
speciesRouter.routes(),
vehiclesRouter.routes(),
starshipRouter.routes(),
planetsRouter.routes(),
peopleRouter.routes(),
],
servePortal
);
❗ Considerações Finais
O projeto está longe de finalizado, ainda tenho alguns itens do Roadmap a ser sanados (como habilitar CORS, atualizar os dados e melhorar a tipagem!) porém estou satisfeito com o resultado atual!
Meu foco no futuro próximo será criar uma imagem Docker
da aplicação e hospeda-la em algum lugar, espero que de graça 😅
Quem quiser ver o código completo, o roadmap e o histórico de alterações pode encontrar tudo isso no repositório GitHub.
Obrigado por lerem este post e até a próxima!
Posted on June 23, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.