Pragiva — Tecnologia na prática

API GET: a listagem e o detalhe de um registro

GET de listagem devolve um resumo por SQL; GET de detalhe busca o registro inteiro por DbSeek pela chave do path. A armadilha do DbSeek de largura fixa.

Protheus Tutorial 5 min de leitura

Assista o vídeo

Resposta rápida

O que muda entre os dois GET
a listagem devolve um resumo, montado por SQL, sem parâmetro obrigatório; o detalhe devolve o registro inteiro, buscado pela chave que chega no path da rota.
Onde a chave chega
:codigo/:loja, na própria string do endpoint — lida em runtime por oRest:getPathParamsRequest(), pela chave sem os dois-pontos.
Por que dois jeitos de ler
um registro já identificado pela chave primária posiciona com DbSeek; um conjunto com filtro variável não tem chave fixa para posicionar, então vira SQL.
A armadilha que não avisa nada
a chave do DbSeek tem largura fixa. Código mais curto que o campo simplesmente não acha o registro — sem erro, só “não encontrado”. PadR com TamSX3() resolve.
O exemplo
pgv.api.clientes.tlpp, sobre a SA1 padrão — lista com filtro opcional por nome e CNPJ, detalhe por código e loja.
Nesta página6 seções

Antes de começar

  • Os dois artigos anteriores da série lidos: este texto não reexplica a sintaxe da rota nem o objeto oRest.
  • Serviço REST do Protheus configurado e respondendo, com a SA1 acessível no ambiente.

O artigo sobre a annotation já mostrou a sintaxe de rota e o path param como string com dois-pontos. Falta a parte que só aparece quando duas rotas do mesmo recurso convivem: por que uma API de cadastro quase sempre tem duas, e por que a forma de ler os dados por trás de cada uma não é a mesma.

Duas rotas, duas responsabilidades

Toda API de cadastro que segue o desenho REST expõe pelo menos dois GET: um que lista, outro que detalha. Não é redundância — é o que evita transportar o registro inteiro pela rede toda vez que a tela só precisa montar uma lista de opções. O pgv.api.clientes.tlpp, anexo a este artigo, declara os dois sobre a mesma tabela padrão SA1: getClientes(), atrás de @Get("/api/pragiva/v1/clientes"), e getClienteDetalhe(), atrás de @Get("/api/pragiva/v1/clientes/:codigo/:loja").

A listagem devolve só o que identifica cada cliente na tela: código, loja, nome, nome reduzido e CNPJ. O detalhe devolve o mesmo cliente, um registro só, buscado pela chave que a própria listagem já entregou. É a tela quem decide quando chamar o segundo GET — ao abrir a lista, chama o primeiro; ao clicar numa linha, chama o segundo com o código e a loja daquela linha.

A entrada que só o detalhe declara

A diferença começa antes de qualquer linha do método rodar: é a própria rota. getClientes() não declara parâmetro nenhum na annotation — o filtro, quando existe, chega por query string, e essa leitura é assunto de outro artigo desta série. getClienteDetalhe() declara dois path params dentro da própria string do endpoint: :codigo/:loja, a mesma sintaxe de dois-pontos que a annotation reconhece para identificar um registro.

Em runtime, os dois valores saem de oRest:getPathParamsRequest(), pela chave igual ao nome declarado depois dos dois-pontos, sem eles:

ADVPL / TLPP
jPath := oRest:getPathParamsRequest()
cCodigo := jPath['codigo']
cLoja := jPath['loja']

Sem os dois path params na URL, a rota nem chega a executar o método — quem já leu o artigo sobre erro esperado e inesperado sabe que o framework responde sozinho, com 404, antes disso.

Um registro conhecido usa workarea; um conjunto variável usa SQL

O mesmo fonte lê dados de duas formas diferentes, e a escolha não é estilo — é o formato de cada pergunta. getClienteDetalhe() já sabe exatamente qual registro quer: código e loja formam a chave primária inteira. Por isso ele posiciona direto:

ADVPL / TLPP
dbSelectArea("SA1")
SA1->(DbSetOrder(1)) // A1_FILIAL+A1_COD+A1_LOJA

If SA1->(DbSeek(xFilial("SA1") + ;
 PadR(cCodigo, TamSX3("A1_COD")[1]) + ;
 PadR(cLoja, TamSX3("A1_LOJA")[1])))

Repare que não há DbGoTop em nenhum lugar deste método: DbSeek, quando encontra a chave, já deixa a área posicionada no registro certo — um DbGoTop depois dele voltaria para o início da tabela e desfaria o posicionamento à toa.

getClientes() resolve um problema diferente: o conjunto que ele devolve varia — zero, um ou dois filtros ativos, cada um opcional. Não existe chave fixa que descreva “talvez por nome, talvez por CNPJ, talvez nenhum dos dois”, e é por isso que a listagem monta a consulta em string e abre com TCQuery, em vez de posicionar uma área de trabalho. O critério, então, não depende do verbo HTTP nem do nome do método: depende de a busca ter ou não uma chave completa e fixa antes de rodar. Registro identificado, DbSeek. Conjunto variável, SQL.

O DbSeek de chave fixa some sem avisar

A armadilha que mais confunde quem vem de outra linguagem está na própria linha do DbSeek acima: a chave de busca é concatenação de campos de largura FIXA, não uma comparação de texto. E o DbSeek casa por prefixo.

Isso é pior do que soa. Se cCodigo chegar com menos caracteres do que tem de tamanho no dicionário, a concatenação fica curta — e o DbSeek não devolve .F.. Ele para no primeiro cliente cujo código começa com aquele valor, Found() responde verdadeiro, e a API devolve 200 com o registro errado. Não é uma busca que falha: é uma busca que acerta outra coisa. O sintoma é um cliente trocado numa tela, sem erro em lugar nenhum, e o bug atravessa o teste manual porque quem testa digita o código completo.

O mesmo prefixo é usado de propósito quando se quer parar no primeiro registro de uma faixa — é recurso, não defeito. O que transforma em defeito é usá-lo sem querer, com uma chave que o consumidor da API montou.

PadR(cCodigo, TamSX3("A1_COD")[1]) resolve completando o valor com espaço à direita até o tamanho do campo. TamSX3() devolve um array com três posições — tamanho, decimais e tipo, nessa ordem — e é a primeira, [1], que interessa aqui. A mesma função entra duas vezes na linha, uma para A1_COD, outra para , porque cada campo pode ter um tamanho diferente no dicionário.

Devolver o registro inteiro sem escrever campo por campo

O getClienteDetalhe() deste exemplo monta a resposta com cinco jItem:Set manuais — proposital, porque o exemplo didático expõe só cinco campos da SA1. Uma API real sobre uma tabela com dezenas de campos não escala assim: o caminho pronto é FWSX3Util():GetAllFields(cAlias, lVirtuais) — instanciado, com parênteses, não chamado direto na classe — devolvendo um array com o registro inteiro do alias informado. O segundo parâmetro, lógico, controla os campos virtuais do dicionário — .T. inclui, .F. exclui.

Confira os campos antes de testar

O SELECT abaixo confirma, no seu ambiente, os campos que os dois GET deste exemplo devolvem:

Confira no seu ambienteSQL
SELECT A1_COD, A1_LOJA, A1_NOME, A1_NREDUZ, A1_CGC
 FROM SA1010
 WHERE D_E_L_E_T_ = ' '
 ORDER BY A1_COD, A1_LOJA

O próximo artigo desta série segue exatamente daqui: o nome e o cnpj que a listagem lê por query string, sem declará-los em rota nenhuma, e o que acontece quando ninguém os envia.


Se sua API devolve “não encontrado” para um cliente que existe, fale com a gente.

Anexos

pgv.api.clientes.tlpp
API REST de clientes em TLPP: listagem com filtro e detalhe por codigo e loja. Exemplo didatico sobre a SA1 padrao.
Baixar fonte
pgv.api.clientes.tlppADVPL / TLPP
#include "tlpp-core.th"
#include "tlpp-rest.th"
#include "topconn.ch"

/*
Exemplo didatico do site Pragiva. API REST de clientes, em classe TLPP com
annotation, sobre a tabela padrao SA1 (clientes). Nao precisa criar nada no
Configurador -- a SA1 ja existe no dicionario padrao, ao contrario do
exemplo MVC desta mesma serie, que usa a tabela ficticia Z50.

O include "topconn.ch" nao e opcional: TCQUERY nao e funcao, e um #xcommand
declarado nele. Sem essa linha o fonte nao compila, e nem "tlpp-core.th" nem
"totvs.ch" o trazem junto.

Para chamar este endpoint o ambiente precisa ter o servico REST do Protheus
configurado (Application Server com o modulo REST habilitado e a porta
liberada).

Rotas:
  GET /api/pragiva/v1/clientes                -> lista, com filtro opcional
  GET /api/pragiva/v1/clientes/:codigo/:loja  -> um cliente
*/

#define TETO_ITENS 200

namespace pragiva.api.clientes

Class ClientesApi
    public method new()

    @Get("/api/pragiva/v1/clientes")
    public method getClientes()

    @Get("/api/pragiva/v1/clientes/:codigo/:loja")
    public method getClienteDetalhe()

    private method responder(nCodigo, jCorpo)
    private method responderErro(nCodigo, cMensagem)

EndClass

Method new() Class ClientesApi
Return Self

/*/{Protheus.doc} getClientes
Lista clientes da SA1, com filtro opcional por nome e por CNPJ.

Sobre "oRest:" x "::" -- os dois aparecem neste metodo, e a diferenca e a
armadilha central de quem migra de WSRESTFUL. "::" e apenas uma abreviacao
de "Self:", entao ele serve para os metodos DESTA classe (::responder,
::responderErro). O objeto da requisicao NAO e desta classe: ele e injetado
pelo runtime da annotation, e por isso so responde por "oRest:". Um
"::setResponse" compila, porque "::" numa classe valida sempre compila, e
so falha quando a requisicao chega -- ClientesApi nao declara esse metodo.
@type method
@return nil
/*/
Method getClientes() Class ClientesApi

    Local jParams := Nil as json
    Local jResp   := JsonObject():New() as json
    Local jItem   := Nil as json
    Local aItens  := {} as array
    Local cNome   := "" as character
    Local cCnpj   := "" as character
    Local cQuery  := "" as character
    Local cAlias  := "" as character
    Local nLidos  := 0 as numeric

    try
        jParams := oRest:getQueryRequest()

        // Uma chamada SEM query string nenhuma e o caso mais comum da
        // listagem, e nessa situacao o retorno pode nao ser objeto. Chamar
        // hasProperty direto ali estoura e devolve 500 numa requisicao que
        // estava perfeitamente correta -- por isso o ValType antes.
        //
        // Com o parametro presente, so entra no WHERE quando vem preenchido:
        // sem isso, um pedido sem "nome" filtraria por nome vazio.
        If ValType(jParams) == "J" .And. jParams:hasProperty("nome") .And. !Empty(jParams["nome"])
            cNome := jParams["nome"]
        EndIf

        If ValType(jParams) == "J" .And. jParams:hasProperty("cnpj") .And. !Empty(jParams["cnpj"])
            cCnpj := jParams["cnpj"]
        EndIf

        cQuery := " SELECT A1_COD, A1_LOJA, A1_NOME, A1_NREDUZ, A1_CGC "
        cQuery += "   FROM " + RetSqlName("SA1") + " SA1 "
        cQuery += "  WHERE SA1.A1_FILIAL = " + ValToSQL(xFilial("SA1"))
        cQuery += "    AND SA1.D_E_L_E_T_ = ' ' "

        // O filtro vem da query string, ou seja, de quem CHAMA a API.
        // Concatenar cru dentro de LIKE '%...%' e injecao de SQL: uma aspa
        // simples no meio do texto fecha o literal e o resto vira comando --
        // da para derrubar o filtro de filial e o D_E_L_E_T_ por ali.
        //
        // ValToSQL resolve UMA coisa: devolve o literal ja com as aspas e
        // remove a aspa simples do conteudo. Ele nao mexe nos curingas do
        // LIKE, e eles tambem vem do usuario: um "%" sozinho casa com tudo e
        // anula o filtro; um "_" casa com qualquer caractere. Quem filtra por
        // texto precisa neutralizar os dois na mao.
        If !Empty(cNome)
            cNome := StrTran(StrTran(cNome, "%", ""), "_", "")
            cQuery += " AND SA1.A1_NOME LIKE " + ValToSQL("%" + cNome + "%")
        EndIf

        If !Empty(cCnpj)
            cQuery += " AND SA1.A1_CGC = " + ValToSQL(cCnpj)
        EndIf

        cAlias := GetNextAlias()

        // Os parenteses em volta do alias sao obrigatorios aqui: o #xcommand
        // usa um marcador que passa o CONTEUDO da variavel quando ha
        // parenteses, e o nome dela como texto quando nao ha. Sem eles, o
        // Protheus abriria uma area chamada literalmente CALIAS.
        // ChangeQuery ajusta a sintaxe da consulta para o banco em uso, e
        // e o habito consolidado de quem abre query por TCQUERY. Sem ela, a
        // consulta pode rodar num banco e falhar em outro por diferenca de
        // dialeto.
        cQuery := ChangeQuery(cQuery)

        TCQUERY cQuery NEW ALIAS (cAlias)

        // Teto de itens: uma listagem sem limite devolve a SA1 inteira na
        // primeira chamada. Paginar de verdade e assunto do FWAdapterBaseV2.
        While !(cAlias)->(Eof()) .And. nLidos < TETO_ITENS
            jItem := JsonObject():New()
            jItem["codigo"] := RTrim((cAlias)->A1_COD)
            jItem["loja"] := RTrim((cAlias)->A1_LOJA)
            jItem["nome"] := RTrim((cAlias)->A1_NOME)
            jItem["nomeReduzido"] := RTrim((cAlias)->A1_NREDUZ)
            jItem["cnpj"] := RTrim((cAlias)->A1_CGC)
            AAdd(aItens, jItem)
            nLidos += 1
            (cAlias)->(DbSkip())
        EndDo

        jResp["items"] := aItens
        jResp["hasNext"] := !(cAlias)->(Eof())

        (cAlias)->(DbCloseArea())

        ::responder(200, jResp)

    catch oErro
        // Fechar a area TAMBEM aqui. Se a excecao acontecer depois do
        // TCQUERY, o caminho de sucesso nunca roda, o DbCloseArea nao
        // acontece e o alias fica aberto na thread REST -- que e
        // reaproveitada entre requisicoes, entao as areas se acumulam.
        If !Empty(cAlias) .And. Select(cAlias) > 0
            (cAlias)->(DbCloseArea())
        EndIf
        ::responderErro(500, oErro:description)
    endtry

Return

/*/{Protheus.doc} getClienteDetalhe
Devolve um cliente unico, buscado pela chave codigo+loja.

O codigo e a loja chegam pelo path da rota, nao por query string. A chave do
dicionario devolvido por getPathParamsRequest e o nome declarado depois dos
dois-pontos na annotation, sem os dois-pontos.
@type method
@return nil
/*/
Method getClienteDetalhe() Class ClientesApi

    Local jPath   := Nil as json
    Local jItem   := JsonObject():New() as json
    Local cCodigo := "" as character
    Local cLoja   := "" as character
    Local aArea   := {} as array

    // A thread REST e reaproveitada entre requisicoes, entao a area de
    // trabalho que este metodo mexe sobrevive a ele. Guardar e restaurar
    // nao e zelo: e o que impede a proxima requisicao de receber a SA1
    // posicionada e na ordem que outro endpoint deixou.
    aArea := GetArea()

    try
        jPath   := oRest:getPathParamsRequest()
        cCodigo := jPath["codigo"]
        cLoja   := jPath["loja"]

        dbSelectArea("SA1")
        SA1->(DbSetOrder(1))  // A1_FILIAL+A1_COD+A1_LOJA

        // Codigo e loja tem largura fixa na SA1, e a chave do DbSeek e a
        // concatenacao desses campos. O DbSeek casa por PREFIXO: um codigo
        // mais curto que o campo NAO devolve "nao encontrado" -- ele para no
        // primeiro cliente cujo codigo COMECA com aquele valor, e a API
        // responde 200 com o cliente errado. PadR ate o tamanho do
        // dicionario completa com espaco e fecha a chave.
        If SA1->(DbSeek(xFilial("SA1") + ;
                        PadR(cCodigo, TamSX3("A1_COD")[1]) + ;
                        PadR(cLoja, TamSX3("A1_LOJA")[1])))

            jItem["codigo"] := RTrim(SA1->A1_COD)
            jItem["loja"] := RTrim(SA1->A1_LOJA)
            jItem["nome"] := RTrim(SA1->A1_NOME)
            jItem["nomeReduzido"] := RTrim(SA1->A1_NREDUZ)
            jItem["cnpj"] := RTrim(SA1->A1_CGC)

            ::responder(200, jItem)
        Else
            // Cliente inexistente e regra de negocio, nao falha tecnica, e
            // por isso NAO usa setFault: setFault recebe um parametro so,
            // o corpo do erro, e nao tem onde receber o codigo HTTP. Quem
            // escolhe o codigo e sempre um setStatusCode em linha separada.
            ::responderErro(404, "cliente nao encontrado")
        EndIf

    catch oErro
        ::responderErro(500, oErro:description)
    endtry

    RestArea(aArea)

Return

/*/{Protheus.doc} responder
Grava codigo, cabecalho e corpo da resposta, no par setStatusCode +
setResponse. Um lugar so, para os dois metodos publicos.
@type method
@return nil
/*/
Method responder(nCodigo, jCorpo) Class ClientesApi
    oRest:setKeyHeaderResponse("Content-Type", "application/json")
    oRest:setStatusCode(nCodigo)
    oRest:setResponse(jCorpo:ToJson())
Return

/*/{Protheus.doc} responderErro
Monta o corpo de erro como OBJETO e serializa com ToJson.

Nao monte JSON concatenando string: a descricao de um erro real carrega
aspas duplas (nome de coluna invalido), barra invertida (caminho de arquivo)
e quebra de linha, e qualquer um dos tres produz um JSON invalido. Quem
escapa isso corretamente e o proprio JsonObject.
@type method
@return nil
/*/
Method responderErro(nCodigo, cMensagem) Class ClientesApi
    Local jErro := JsonObject():New() as json
    jErro["erro"] := cMensagem
    ::responder(nCodigo, jErro)
Return

Referências

  1. TDNtdn.totvs.com

    TDN — REST (TOTVSTEC)
  2. TDNtdn.totvs.com

    TDN — Entendendo as novidades do RESTIndicada pelo catálogo como visão geral da annotation.

Continue por aqui

  1. Vídeo

    ProtheusReferência técnica

    A annotation que vira endpoint REST

    @Get, @Post, @Put, @Delete e @Patch viram endpoint REST em TLPP acima de um método ou função. A caixa não importa; path param é :nome na rota.

    5 min de leituraAssista o vídeo