DEV Community

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

Posted on • Edited on

2 1

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!

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up