Como ChiselStrike gera sua API de cliente TypeScript

Jan 11 2023
Duas semanas atrás, a ChiselStrike anunciou um novo gerador de SDK de cliente TypeScript que lida com os detalhes de invocar a API REST gerada automaticamente para seu modelo de dados. Você pode usar este SDK em seu web e Node.

Duas semanas atrás, a ChiselStrike anunciou um novo gerador de SDK de cliente TypeScript que lida com os detalhes de invocar a API REST gerada automaticamente para seu modelo de dados. Você pode usar esse SDK em seus aplicativos Web e Node.js para facilitar o trabalho com dados de entidades. A melhor parte é que ele preserva os tipos de todos os dados que passam pela rede, portanto, tenha maior garantia de que seu código fará o que você espera.

Executar uma consulta para todas as instâncias de uma entidade de maneira segura é tão fácil quanto escrever isto:

const client = createChiselClient({ serverUrl })
const posts: BlogPost[] = await chiselClient.posts.getAll()

Nesta série de postagens de blog, vou orientá-lo no processo de implementação chisel generatee compartilhar alguns dos aspectos interessantes desse empreendimento que descobri ao longo do caminho. E mesmo que você não seja um entusiasta da geração de código, espero que tire uma coisa de tudo isso: a geração de código não precisa ser intimidante!

O que estamos gerando?

Para ilustrar qual é o objetivo, vamos considerar um projeto ChiselStrike com uma única entidade (bastante simplificada):

// models/blog_post.ts
export class BlogPost extends ChiselEntity {
    text: string;
    tags: string[];
    publishedAt: Date;
    authorName: string;
    karma: number;
}

// routes/index.ts
export default new RouteMap()
    .prefix("/blog/posts", BlogPost.crud());

Ao projetar uma nova API, geralmente é útil começar com o uso desejado e se preocupar com a implementação posteriormente. Aqui está o que parece, dada a entidade e rota acima.

Em primeiro lugar, uma função de fábrica para criar o objeto cliente com alguma configuração (no mínimo, um endpoint de URL):

const client = createChiselClient({ serverUrl });

const post: BlogPost = await client.blog.posts.post({
    text: "To like or not to like Trains, that is the question!",
    tags: ["lifeStyle"],
    publishedAt: new Date(),
    authorName: "I Like Trains",
    karma: 0,
});

const updatedPost: BlogPost = await client.blog.posts.id(“[YOUR-ID]”).patch({
    tags: ["lifeStyle", "philosophy"],
});

No código acima, você pode ver que ambos post()aceitam patch()um objeto BlogPost como um argumento e também retornam um BlogPost — esse é o tipo de segurança nesta API! O que precisamos fazer é gerar esse tipo para o cliente usar (o aplicativo cliente não pode compartilhar código de entidade com o back-end — não podemos simplesmente reutilizar a classe de entidade BlogPost original como você pode esperar).

O que vamos fazer para obter esse tipo de BlogPost para o cliente é consultar chiseldos dados do modelo, transformá-lo em um tipo TypeScript de aparência semelhante e armazená-lo em um arquivo dedicado chamado models.ts.

O tipo de entidade gerada será um pouco diferente da entidade original que subclasse ChiselEntity, pois não precisamos de nada tão complexo quanto uma classe para o cliente. Também precisamos adicionar um idcampo explícito, pois todos os ChiselEntities herdam um. É assim que o tipo final precisa ser para o cliente:

// models.ts
export type BlogPost = {
    id: string;
    text: string;
    tags: string[];
    publishedAt: Date;
    authorName: string;
    karma: number;
};

fn generate_models(version_def: &VersionDefinition) -> Result<String> {
    let mut output = String::new();
    for def in &version_def.type_defs {
         writeln!(output, "export type {} = {{", def.name)?;
         for field in &def.field_defs {
             let field_type = field.field_type()?;
             writeln!(
                 output,
                 " {}{}: {};",
                 field.name,
                 if field.is_optional { "?" } else { "" },
                 type_enum_to_code(field_type)?
             )?;
         }
         writeln!(output, "}}")?;
    }
    Ok(output)
}

fn type_enum_to_code(type_enum: &TypeEnum) -> Result<String> {
    let ty_str = match &type_enum {
         TypeEnum::ArrayBuffer(_) => "ArrayBuffer".to_owned(),
         TypeEnum::Bool(_) => "boolean".to_owned(),
         TypeEnum::JsDate(_) => "Date".to_owned(),
         TypeEnum::Number(_) => "number".to_owned(),
         TypeEnum::String(_) | TypeEnum::EntityId(_) => "string".to_owned(),
         TypeEnum::Array(element_type) => {
             format!("{}[]", type_enum_to_code(element_type)?)
         }
         TypeEnum::Entity(entity_name) => entity_name.to_owned(),
    };
    Ok(ty_str)
}

Se você se aprofundar, poderá ver que o código emite uma export typeinstrução com um espaço reservado para o nome do tipo e, em seguida, emite iterativamente propriedades individuais (campos) com seu tipo correspondente.

Isso nos leva da definição de classe de entidade original para o tipo gerado para o cliente. Muito fácil até agora. Reconhecidamente, economizamos muito trabalho extraindo os tipos já analisados ​​do back-end do ChiselStrike, mas, no futuro, esperamos eliminar essa dependência.

Gerando o objeto cliente

Agora que temos nossos tipos, é hora de focar no próprio objeto cliente. Para fazê-lo funcionar conforme ilustrado em nossos exemplos anteriores, podemos aproveitar os tipos de objeto do TypeScript e aninhá-los para criar a estrutura necessária. É simples, digitado e fácil de gerar em comparação com várias classes. Aqui está a aparência do objeto cliente:

const client = {
  blog: {
    posts: {
      post: (blogPost: Omit<BlogPost, "id">) => Promise<BlogPost> {...},
      // put, get, delete functions missing here
      id: (id: string) => {
        return {
          patch: (blogPost: Partial<BlogPost>) => Promise<BlogPost> {...}, …
          // get, delete missing here
        }
      }
    }
  }
}

Mesmo que estejamos planejando gerar o código, ele precisa de mais estrutura. Ok, vamos começar substituindo post: (user: Omit<User, “id”>) => Promise<User> {…}, por algo mais completo.

Gerando a função do manipulador POST (em TypeScript)

Que tal, em vez de gerar a função em Rust, escrevermos uma função TypeScript que geraria o manipulador para nós? É muito fácil fazer isso usando as funções de seta:

function makePostOne<Entity extends Record<string, unknown>>(
    url: URL,
    entityType: reflect.Entity,
): (entity: OmitRecursively<Entity, "id">) => Promise<Entity> {
      return async (entity: OmitRecursively<Entity, "id">) => {
           const entityJson = entityToJson(entityType, entity);
           const resp = await sendJson(url, "POST", entityJson);
           await throwOnError(resp);
           return entityFromJson<Entity>(entityType, await resp.json());
      };
  }

A makePostOne()função leva urle entityTypeparâmetros. O urlnos diz para onde a solicitação será enviada e o entityTypeparâmetro reflection é usado para converter nossa entidade de e para JSON. Mais sobre isso mais tarde.

A makePostOne()função retorna uma função de seta assíncrona com a assinatura (entity: OmitRecursively<Entity, “id”>) => Promise<Entity>. O parâmetro da entidade deve ser OmitRecursively<Entity, “id”>porque quando estamos postando (criando) uma nova entidade, ainda não temos um ID. O banco de dados irá fornecer isso para nós. Como todos os nossos tipos de entidade gerados contêm o campo id, precisamos omiti-lo e fazê-lo de maneira recursiva porque as entidades podem ser aninhadas. (Para saber mais sobre a implementação de OmitRecursively, leia aqui .) O tipo de retorno não precisa dessa restrição porque a função retorna a entidade recém-criada incluindo o idcampo.

Agora, para o corpo da função. O back-end ChiselStrike aceita entidades como JSON, então primeiro fazemos essa conversão usando entityToJson.

const entityJson = entityToJson(entityType, entity);

Esse processo nos fornece um objeto compatível com JSON que simplesmente enviamos pela rede usando sendJson.

const resp = await sendJson(url, "POST", entityJson);

await throwOnError(resp);

return entityFromJson<Entity>(entityType, await resp.json());

function createClient(serverUrl: string) {
  const url = (url: string) => {
    return urlJoin(serverUrl, url);
  };
  return  {
    blog: {
     posts: {
        post: makePostOne<BlogPost>(url(`/blog/posts`), reflection.BlogPost),
        delete: makeDeleteMany<BlogPost>(url(`/blog/posts`)),
        id: (id: string) => {
          return {
            delete: makeDeleteOne(url(`/blog/posts/${id}`)),
            patch: makePatchOne<BlogPost>(
              url(`/blog/posts/${id}`),
              reflection.BlogPost,
            ),
            put: makePutOne<BlogPost>(
              url(`/blog/posts/${id}`),
              reflection.BlogPost,
            ),
          };
        },
      },
    },
  };
}

Qual é o próximo

Você deve ter notado que nosso cliente ainda não tem a capacidade de 'pegar' as coisas. Os endpoints CRUD GET do ChiselStrike fornecem um rico conjunto de opções de filtragem e parâmetros adicionais. Oferecer suporte a tudo isso de maneira segura não é trivial, e falarei sobre isso em um post futuro. Depois disso, discutiremos o code-behind entityToJson()e entityFromJson()as funções e mecanismos de reflexão relacionados.

Para ser notificado sobre essas postagens futuras, considere seguir-nos aqui no Medium , Twitter ou Discord . Espero ver você lá!