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 poroRest: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
DbSeektem largura fixa. Código mais curto que o campo simplesmente não acha o registro — sem erro, só “não encontrado”.PadRcomTamSX3()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:
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:
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
A1_COD 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 A1_LOJA, 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:
SELECT A1_COD, A1_LOJA, A1_NOME, A1_NREDUZ, A1_CGC
FROM SA1010
WHERE D_E_L_E_T_ = ' '
ORDER BY A1_COD, A1_LOJAO 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
#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)
ReturnReferências
TDNtdn.totvs.com
TDN — REST (TOTVSTEC)TDNtdn.totvs.com
TDN — Entendendo as novidades do RESTIndicada pelo catálogo como visão geral da annotation.
Continue por aqui
- 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.