DEV Community

Cover image for Reescrevendo a StarWars API em Deno
Rodolpho Alves
Rodolpho Alves

Posted on • Updated on

Reescrevendo a StarWars API em Deno

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.

GitHub logo 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:

  1. Suporte nativo a Typescript
  2. Versionamento integrado ao Git (nada de packages.json ou o inferno do node_modules)
  3. 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:

  1. Oak
  2. Drash
  3. Dactyl [obs: Baseado no Oak]

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:

  1. Film
  2. Person
  3. Planet
  4. Specie
  5. Starship
  6. 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": [
    { ... }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 ?? [];
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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:

  1. Menos "verboso" para declarar componentes
  2. Não depender de um CLI para um kickstart da configuração
  3. 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 -->
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

A cara final do portal ficou assim:
Alt Text

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
);
Enter fullscreen mode Exit fullscreen mode

❗ 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!

Oldest comments (0)