Endpoint
Quando usar: sempre que o módulo precisa publicar uma operação — uma leitura ou uma mutação que o cliente chama pelo gateway.
O caminho, o método HTTP, a permissão, o limite de corpo e a idempotência moram no descritor
(nnp/api.yaml). O método Java só diz QUAL rota ele atende, por id.
O descritor primeiro
nnp/api.yaml:
apiVersion: console.nnumbers.io/v1alpha3
kind: ApiDescriptor
service: sample
public_api: {prefix: /v1/sample}
internal_api: {prefix: /internal/v1/sample}
routes:
- {id: items.list, method: GET, path: /items, permission: sample.item.read}
- {id: items.get, method: GET, path: "/items/{item_id}", permission: sample.item.read}
- {id: items.create, method: POST, path: /items, permission: sample.item.create, idempotency: required, max_body_bytes: 4096}
O nome do parâmetro no caminho ({item_id}) é o nome que o handler lê depois. O prefixo público é o
mesmo que o OpenAPI declara em nns/api/openapi.yaml.
O handler
java/src/main/java/br/com/nnumbers/console/sample/app/ItemsController.java:
package br.com.nnumbers.console.sample.app;
import br.com.nnumbers.console.sample.store.Item;
import br.com.nnumbers.console.sample.store.ItemStore;
import br.com.nnumbers.console.sdk.apierr.ApiError;
import br.com.nnumbers.console.sdk.apierr.Code;
import br.com.nnumbers.console.sdk.ctx.Ctx;
import br.com.nnumbers.console.sdk.descriptor.Match;
import br.com.nnumbers.console.sdk.nns.ServiceChain;
import br.com.nnumbers.console.sdk.spring.web.NnsRoute;
import br.com.nnumbers.console.sdk.svcctx.ServiceContext;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Profile("serve")
class ItemsController {
private final SampleService service;
ItemsController(SampleService service) {
this.service = service;
}
/** `items.list`: `?limit=&cursor=`. */
@NnsRoute("items.list")
Map<String, Object> list(Ctx ctx, ServiceContext sc, ServiceChain.Dispatch d) {
String tenant = sc.requireTenantId();
String raw = d.target().rawQueryText();
int limit = SampleService.DEFAULT_LIST_LIMIT;
String s = Wire.queryParam(raw, "limit");
if (!s.isEmpty()) {
long n;
try {
n = Long.parseLong(s);
} catch (NumberFormatException e) {
n = -1;
}
if (n < 1 || n > SampleService.MAX_LIST_LIMIT) {
throw ApiError.of(
Code.VALIDATION_FAILED,
"invalid_limit",
"limit entre 1 e " + SampleService.MAX_LIST_LIMIT + ".")
.withDetail("max", SampleService.MAX_LIST_LIMIT);
}
limit = (int) n;
}
byte[] cursor = Wire.queryParam(raw, "cursor").getBytes(java.nio.charset.StandardCharsets.US_ASCII);
ItemStore.Page p = ItemStore.list(ctx, service.db, tenant, limit, cursor);
List<Views.ItemView> items = new ArrayList<>(p.items().size());
for (Item it : p.items()) {
items.add(Views.ItemView.of(it));
}
Map<String, Object> out = new TreeMap<>();
out.put("items", items);
out.put("next_cursor", p.nextCursor().isEmpty() ? null : p.nextCursor());
return out;
}
/** `items.get`: o item do tenant; de outro tenant é 404, nunca 403. */
@NnsRoute("items.get")
Views.ItemView get(Ctx ctx, ServiceContext sc, Match m) {
String tenant = sc.requireTenantId();
return Views.ItemView.of(ItemStore.get(ctx, service.db, tenant, m.params().get("item_id"), false));
}
}
Três coisas para notar:
- Nada de
@GetMapping/@RequestMapping. O método leva só@NnsRoute("<id>"). Um método com anotação de MVC impede a partida do serviço. - O que você pode pedir como parâmetro:
Ctx(ids e prazo da requisição),ServiceContext(o contexto verificado),Match(o casamento da rota, com os parâmetros do caminho),ServiceChain.Dispatch(cabeçalhos e alvo crus),NnsBody(os bytes do corpo),Introspection,Token(nas rotas internas) — além do que o Spring MVC resolve (@PathVariable,@RequestHeader,HttpServletResponse). - O que você devolve vira JSON pelo mapeador do contrato, com
Content-Type: application/json; charset=utf-8,Cache-Control: no-storee quebra de linha final. Não monte a resposta na mão.
A vista é um record à parte
O corpo do banco não vai para a rede. A vista tem os campos que o contrato publica, em snake_case,
e é ela que aparece no OpenAPI:
package br.com.nnumbers.console.sample.app;
import br.com.nnumbers.console.sample.store.Finding;
import br.com.nnumbers.console.sample.store.Item;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public final class Views {
private Views() {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ItemView(
@JsonProperty("id") String id,
@JsonProperty("name") String name,
@JsonProperty("description") String description,
@JsonProperty("created_at") String createdAt,
@JsonProperty("created_by") String createdBy,
@JsonProperty("scan") ScanView scan) {
static ItemView of(Item it) {
return new ItemView(
it.id(),
it.name(),
it.description(),
it.createdAt().toString(),
it.createdBy(),
new ScanView(
it.scanStatus(),
it.scanEngine(),
it.scanActionId().isEmpty() ? null : it.scanActionId(),
it.score(),
it.scannedAt() == null ? null : it.scannedAt().toString(),
it.findings()));
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ScanView(
@JsonProperty("status") String status,
@JsonProperty("engine") String engine,
@JsonProperty("action_id") String actionId,
@JsonProperty("score") Integer score,
@JsonProperty("scanned_at") String scannedAt,
@JsonProperty("findings") List<Finding> findings) {}
}
Como testar
Dois níveis, os dois baratos:
// 1. a rota responde e o contexto do token é o que chega (sem banco, serviço de verdade numa porta)
@Test
void whoamiShowsTheVerifiedContextOfTheToken() {
String token = ctxToken(ctx("whoami", "sample.context.read"), "GET", "/v1/sample/whoami", null);
HttpResponse<String> r = call("GET", "/v1/sample/whoami", token, null);
assertThat(r.statusCode()).isEqualTo(200);
assertThat(body(r).get("tenant_id").asText()).isEqualTo("4");
}
// 2. o isolamento por tenant é do banco, então ele se prova no MySQL
@Test
void nameIsUniquePerTenantIgnoringCaseAndAccent() {
Item a = item("t1", "Ação", T0);
ItemStore.create(CTX, db, a);
assertThatThrownBy(() -> ItemStore.create(CTX, db, item("t1", "acao", T0)))
.isInstanceOfSatisfying(
ApiError.class, e -> assertThat(e.reason()).isEqualTo(Scan.REASON_ITEM_NAME_TAKEN));
}
O teste HTTP de verdade (com token assinado e o serviço no ar numa porta) está em
java/src/test/java/br/com/nnumbers/console/sample/app/HttpAuthorizationTest.java; a receita
Testes mostra o arcabouço inteiro.
Erros comuns
@GetMappingno lugar de@NnsRoute— o serviço não sobe:nns: rotas do api.yaml sem handlerou rota registrada fora do descritor.- Id de rota que não existe no
api.yaml— a partida recusa com o id em questão. Oapi.yamlé a fonte da verdade: rota que não está lá não existe. - Nome do parâmetro diferente entre o caminho (
{item_id}) e o que o handler lê (m.params().get("item_id")) — o parâmetro chega vazio, e o erro vira 404 enganoso. - Esquecer a permissão da rota no
iam.yaml— odescriptors validateacusa; sem isso a rota existe e ninguém consegue chamá-la. - Devolver
ResponseEntity<byte[]>numa leitura — funciona, mas você perde o mapeador do contrato e passa a ser dono doContent-Type, dono-storee da quebra de linha. - Ler o corpo com
@RequestBody— recusado: o handler recebeNnsBodye decodifica com o JSON estrito do SDK (veja Erros). - Esquecer
max_body_bytesna rota de mutação — o teto passa a ser o padrão do runtime, e um corpo grande falha longe do handler.
Esta página ajudou?
Reportar um problema nesta páginaNão envie senhas, chaves, tokens ou dados de clientes.