Pelo SDK TypeScript
O SDK implementa a própria interface do plugin (IJornadaHub, IOcorrenciaHub) do outro lado da rede, e a mesma suíte de contrato que verifica o roda contra ele. Sem caminho, sem status, sem cabeçalho: "funciona" deixa de ser opinião.
Instalar e começar
O SDK é o pacote @geocontrol/geohub-sdk, com uma entrada por plugin: @geocontrol/geohub-sdk/jornada e @geocontrol/geohub-sdk/ocorrencia. Cada uma exporta uma função, criarClienteJornada e criarClienteOcorrencia, que recebe a raiz do Geohub (https://geohub.exemplo.gov.br), o token do usuário de serviço e a autoria (em nome de quem o CAD age), e devolve o cliente com os mesmos verbos do hub — comandos, leituras e eventos no mesmo nível. Onde os plugins estão montados é assunto do SDK, não seu. O vocabulário tipado (ids, matricula(), autoriaDeIntegrador()) sai da mesma entrada; e quem já tem um hub em mãos o embrulha com clienteDeUmAtor, de @geocontrol/geohub-sdk/cliente.
Hoje o SDK não está publicado no npm
O pacote vive no repositório do e exporta essas entradas como fonte TypeScript. Quem integra de fora consome a REST gerada, que tem o mesmo contrato (REST e WebSocket); a publicação no npm está decidida e vem a seguir. Esta página continua valendo para quem tem o repositório, e é a forma mais curta de ler o contrato.
O que todo passo precisa, e só isso: a raiz do Geohub, o token do usuário de serviço e em nome de quem o CAD age (a autoria, feita do id do integrador). Os três entram uma vez, na conexão; o cliente que sai dela tem os comandos, as leituras e os eventos no mesmo nível. Os passos da documentação leem isso do ambiente, e o teste do pacote exemplo-integrador-despacho roda cada um deles contra o Geohub em processo.
export const GEOHUB =
process.env['GEOHUB_URL'] ?? 'https://geohub.exemplo.gov.br';
export const TOKEN =
process.env['GEOHUB_TOKEN'] ?? 'TOKEN_DO_USUARIO_DE_SERVICO';
export const INTEGRADOR = integradorId(
process.env['GEOHUB_INTEGRADOR'] ?? 'ID_DO_USUARIO_DE_SERVICO',
);
/** A conexão que todo cliente do SDK recebe: onde, com que token, em nome de quem. */
export const CONEXAO = {
autoria: autoriaDeIntegrador(INTEGRADOR),
geohub: GEOHUB,
token: TOKEN,
};export const GEOHUB =
process.env['GEOHUB_URL'] ?? 'https://geohub.exemplo.gov.br';
export const TOKEN =
process.env['GEOHUB_TOKEN'] ?? 'TOKEN_DO_USUARIO_DE_SERVICO';
export const INTEGRADOR = integradorId(
process.env['GEOHUB_INTEGRADOR'] ?? 'ID_DO_USUARIO_DE_SERVICO',
);
/** A conexão que todo cliente do SDK recebe: onde, com que token, em nome de quem. */
export const CONEXAO = {
autoria: autoriaDeIntegrador(INTEGRADOR),
geohub: GEOHUB,
token: TOKEN,
};export GEOHUB=https://geohub.exemplo.gov.br
export TOKEN=… # o token estático do usuário de serviço, ou o de um login
export INTEGRADOR=… # o id do usuário de serviço, o mesmo de GET /users/meO primeiro passo de um CAD: registrar uma e complementá-la.
import { criarClienteOcorrencia } from '@geocontrol/geohub-sdk/ocorrencia';
import { CONEXAO } from './conexao.js';
const ocorrencia = criarClienteOcorrencia(CONEXAO);
// O cliente cunha um UUID v7 por comando: é a chave de idempotência.
const registro = await ocorrencia.registrarOcorrencia({
classificacao: { natureza: 'roubo a transeunte', prioridade: 1 },
local: {
endereco: {
logradouro: 'Av. Dante Michelini',
municipio: 'Vitória',
uf: 'ES',
},
},
// O protocolo no CAD: identifica uma só ocorrência por integrador.
referenciaExterna: 'CAD-2026-004512',
solicitante: { canal: 'telefone' },
});
if (!registro.success) throw new Error(registro.error.codigo);
export const ocorrenciaId = registro.data;
// Complementar depois: só dados de negócio, nunca o status.
export const atualizacao = await ocorrencia.atualizarOcorrencia({
alteracoes: { descricao: 'Dois suspeitos em motocicleta.' },
id: ocorrenciaId,
});import { criarClienteOcorrencia } from '@geocontrol/geohub-sdk/ocorrencia';
import { CONEXAO } from './conexao.js';
const ocorrencia = criarClienteOcorrencia(CONEXAO);
// O cliente cunha um UUID v7 por comando: é a chave de idempotência.
const registro = await ocorrencia.registrarOcorrencia({
classificacao: { natureza: 'roubo a transeunte', prioridade: 1 },
local: {
endereco: {
logradouro: 'Av. Dante Michelini',
municipio: 'Vitória',
uf: 'ES',
},
},
// O protocolo no CAD: identifica uma só ocorrência por integrador.
referenciaExterna: 'CAD-2026-004512',
solicitante: { canal: 'telefone' },
});
if (!registro.success) throw new Error(registro.error.codigo);
export const ocorrenciaId = registro.data;
// Complementar depois: só dados de negócio, nunca o status.
export const atualizacao = await ocorrencia.atualizarOcorrencia({
alteracoes: { descricao: 'Dois suspeitos em motocicleta.' },
id: ocorrenciaId,
});curl -X POST $GEOHUB/plugins/ocorrencias -H "Authorization: Bearer $TOKEN" \
-H "x-solicitacao-id: $(uuidv7)" -H "Content-Type: application/json" \
-d '{"classificacao":{"natureza":"roubo a transeunte","prioridade":1},"solicitante":{"canal":"telefone"},"referenciaExterna":"CAD-2026-004512"}'
# {"dados":"<ocorrenciaId>"}Os outros passos estão nos casos de uso: pôr em serviço e constituir a guarnição e despachar a viatura. Três coisas que eles já fazem certo e que o resto da página explica: um UUID v7 por comando, cunhado antes de sair; a falha de negócio devolvida como valor, não lançada; e o recurso do montado com a que o Geohub conhece, não com a composição que o CAD imagina.
Dois clientes, um contrato
Há dois jeitos de obter um cliente, e a diferença é só o transporte:
| cliente | transporte | para quem |
|---|---|---|
criarClienteJornada / criarClienteOcorrencia (@geocontrol/geohub-sdk/jornada, …/ocorrencia) | a REST gerada, com x-solicitacao-id por comando | o |
jornadaRemota / ocorrenciaRemota (@geocontrol/jornadahub/remoto, …/ocorrenciahub/remoto) | a invocação (/invocar) com polling de eventos por inscrição | o , que precisa de .on('ocorrencia:despachada', …) na tela |
Os dois recebem a raiz do Geohub (geohub). O primeiro leva a autoria na conexão e uma credencial só, e o tipo não deixa faltar nem sobrar: o token do usuário de serviço (com um buscar opcional, que é só o transporte), ou a buscaAutenticada de uma sessão que renova sozinha. O segundo é multiator (como(autoria) por chamada) até ser embrulhado por clienteDeUmAtor, e leva a mesma credencial. Para o policial, sempre a busca autenticada: o token dele muda a cada renovação, e um token fixo falharia com 401 na primeira.
Tudo o que o cliente recebe ou devolve sai da mesma entrada com nome — os agregados (Jornada, Guarnicao, Ocorrencia, Despacho) e seus estados, os status, os códigos e as falhas (discriminadas por codigo, com contexto só onde existe), os payloads e o envelope dos eventos. A recusa de protocolo é ErroDoClienteRest, e ehErroDoClienteRest (de @geocontrol/geohub-sdk/cliente) a reconhece pelo nome.
O vocabulário vem com o SDK
Os ids do domínio são tipos marcados: um JornadaId não é um GuarnicaoId, e nenhum dos dois é string. O SDK exporta os tipos e os construtores (jornadaId(texto), viaturaId('RP-1207'), matricula(texto)), então o integrador tipa o próprio código com eles e o compilador impede passar o id errado ao verbo errado. O texto do mundo de fora vira valor do domínio uma vez, na fronteira: a matrícula que o CAD tem vira Matricula ao entrar, e o corpo de uma resposta REST vira OcorrenciaId ao sair do fetch. É assim que os dois exemplos são escritos: nenhum as fabrica um id, e o que resta de as é a leitura do JSON na fronteira.
O app do policial
Três peças: a entrada de autenticação do SDK para a sessão (o
do outro lado), a busca autenticada que renova sozinha, e osdois hubs remotos. É o caminho do pacote exemplo-app-do-policial, que roda contra o Geohub em processo.
O hub remoto é multiator; o app tem um usuário só. O serviço do policial o embrulha num cliente de um ator (@geocontrol/geohub-sdk/cliente), e a autoria do usuário entra uma vez, no construtor:
private readonly jornada: ClienteDeUmAtor<IJornadaHub, IComandosDeJornada>;
constructor(
hub: IJornadaHub,
private readonly usuario: UsuarioId,
) {
this.jornada = clienteDeUmAtor(hub, autoriaDeUsuario(usuario));
}jornada;
constructor(hub, usuario) {
this.usuario = usuario;
this.jornada = clienteDeUmAtor(hub, autoriaDeUsuario(usuario));
}Entrar
/**
* Entra no Geohub com matrícula e senha e devolve a sessão.
*
* Toda rejeição chega tipada (`credenciais_invalidas`, `usuario_bloqueado`,
* `central_indisponivel`…), embora o Geohub entregue todas como `401`: é o
* código do erro que as separa, traduzido dentro do SDK.
*/
export async function entrarNoGeohub(
conexao: ConexaoComOGeohub,
credenciais: CredenciaisDoPolicial,
): Promise<SessaoDoPolicial> {
const buscar = conexao.buscar ?? globalThis.fetch;
const autoriza = new ClienteDeAutenticacao({
buscar,
geohub: conexao.geohub,
// Matrícula → e-mail é convenção do deployment, nunca da tela.
resolucao: resolucaoPorSufixo(conexao.sufixoDaMatricula ?? '@pm.br'),
});
const resposta = await autoriza.entrar({
// A matrícula é o identificador de login deste deployment.
identificador: identificador(credenciais.matricula),
senha: credenciais.senha,
});
if (resposta.status !== 'autenticado') {
throw new EntradaRecusadaError(resposta.motivo);
}
return {
// Põe o Bearer em toda requisição e, num 401, renova uma vez e repete.
buscar: criarBuscaAutenticada(autoriza, buscar),
sair: () => autoriza.sair(),
usuario: resposta.sessao.usuario,
};
}/**
* Entra no Geohub com matrícula e senha e devolve a sessão.
*
* Toda rejeição chega tipada (`credenciais_invalidas`, `usuario_bloqueado`,
* `central_indisponivel`…), embora o Geohub entregue todas como `401`: é o
* código do erro que as separa, traduzido dentro do SDK.
*/
export async function entrarNoGeohub(conexao, credenciais) {
const buscar = conexao.buscar ?? globalThis.fetch;
const autoriza = new ClienteDeAutenticacao({
buscar,
geohub: conexao.geohub,
// Matrícula → e-mail é convenção do deployment, nunca da tela.
resolucao: resolucaoPorSufixo(conexao.sufixoDaMatricula ?? '@pm.br'),
});
const resposta = await autoriza.entrar({
// A matrícula é o identificador de login deste deployment.
identificador: identificador(credenciais.matricula),
senha: credenciais.senha,
});
if (resposta.status !== 'autenticado') {
throw new EntradaRecusadaError(resposta.motivo);
}
return {
// Põe o Bearer em toda requisição e, num 401, renova uma vez e repete.
buscar: criarBuscaAutenticada(autoriza, buscar),
sair: () => autoriza.sair(),
usuario: resposta.sessao.usuario,
};
}curl -X POST https://geohub.exemplo.gov.br/auth/login -H "Content-Type: application/json" \
-d '{"email":"123456@pm.br","password":"…","mode":"json"}'
# {"data":{"access_token":"…","refresh_token":"…","expires":900000}} (expires em ms)
curl "https://geohub.exemplo.gov.br/users/me?fields=id,role" -H "Authorization: Bearer $TOKEN"
# {"data":{"id":"<UsuarioId>","role":"<PapelId>"}}
curl -X POST https://geohub.exemplo.gov.br/auth/refresh -H "Content-Type: application/json" -d '{"refresh_token":"…","mode":"json"}' # o refresh token é de uso único
curl -X POST https://geohub.exemplo.gov.br/auth/logout -H "Content-Type: application/json" -d '{"refresh_token":"…","mode":"json"}'O cliente faz POST /auth/login com o e-mail resolvido da matrícula, depois GET /users/me?fields=id,role, e guarda o par de tokens, o UsuarioId e o papel. Toda rejeição chega tipada: credenciais_invalidas, usuario_bloqueado, usuario_desconhecido, token_expirado, permissao_negada (usuário sem papel) e central_indisponivel (erro 5xx ou sem rede). As mensagens de tela vêm de mensagemDeRejeicao(motivo).
A busca autenticada renova o token uma vez num 401 e repete a chamada; um 403 não renova, porque ali o token vale e o papel é que não permite. Renovações concorrentes dividem a mesma chamada, porque o refresh token do Geohub é de uso único.
Ligar os hubs
/** Liga os dois hubs remotos com a sessão do policial. */
export async function ligarHubs(conexao: ConexaoDosHubs): Promise<HubsDoApp> {
const polling = conexao.polling ?? { aCada: milissegundos(1500) };
const intervaloDeEventos = polling === 'desligado' ? 0 : polling.aCada;
const jornada = jornadaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
const ocorrencia = ocorrenciaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
// `iniciar()` só liga o polling DESTE cliente; o plugin é do servidor.
await Promise.all([jornada.iniciar(), ocorrencia.iniciar()]);
return {
desligar: async () => {
await Promise.all([jornada.parar(), ocorrencia.parar()]);
},
jornada,
ocorrencia,
};
}/** Liga os dois hubs remotos com a sessão do policial. */
export async function ligarHubs(conexao) {
const polling = conexao.polling ?? { aCada: milissegundos(1500) };
const intervaloDeEventos = polling === 'desligado' ? 0 : polling.aCada;
const jornada = jornadaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
const ocorrencia = ocorrenciaRemota({
geohub: conexao.geohub,
buscaAutenticada: conexao.buscaAutenticada,
intervaloDeEventos,
});
// `iniciar()` só liga o polling DESTE cliente; o plugin é do servidor.
await Promise.all([jornada.iniciar(), ocorrencia.iniciar()]);
return {
desligar: async () => {
await Promise.all([jornada.parar(), ocorrencia.parar()]);
},
jornada,
ocorrencia,
};
}curl https://geohub.exemplo.gov.br/plugins/jornada/manifesto
curl https://geohub.exemplo.gov.br/plugins/ocorrencia/manifesto
curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencia/invocar -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"verbo":"aceitarDespacho","args":["…"],"solicitacaoId":"…"}'
curl "https://geohub.exemplo.gov.br/plugins/ocorrencia/eventos?inscricao=$INSCRICAO" -H "Authorization: Bearer $TOKEN"
curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencia/eventos/encerrar -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"inscricao":"'$INSCRICAO'"}'Com os hubs ligados, cada é o que as páginas dos casos mostram: entrar em serviço, atender e registrar em campo. Todo comando devolve um Result: { success: true, data } ou { success: false, error: { codigo, detalhe?, contexto? } }. Falha de negócio é valor; recusa de protocolo (401, 403 SEM_CAPACIDADE) é exceção do cliente remoto.
Ao sair, pare o polling antes do logout: um timer vivo pediria eventos com uma sessão que não existe mais.
O integrador (CAD)
Três coisas valem para todo comando do integrador: o token do usuário de serviço, o solicitacaoId cunhado antes de sair (UUIDv7, um por comando) e o retry que repete a mesma chave.
/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(
credenciais: CredenciaisDoIntegrador,
): Promise<SessaoDoIntegrador> {
const buscar = credenciais.buscar ?? globalThis.fetch;
const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
body: JSON.stringify({
email: credenciais.email,
password: credenciais.senha,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
const corpo = (await resposta.json()) as RespostaDeLogin;
const token = corpo.data?.access_token;
if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
return new SessaoDoIntegrador(
credenciais.geohub,
token,
credenciais.integradorId,
credenciais.buscar,
credenciais.esperar,
);
}
/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes: TokenDoIntegrador): SessaoDoIntegrador {
return new SessaoDoIntegrador(
opcoes.geohub,
opcoes.token,
opcoes.integradorId,
opcoes.buscar,
opcoes.esperar,
);
}
/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
readonly autoria: Autoria;
/** O `fetch` desta sessão. É ele que o SDK recebe. */
readonly buscar: typeof fetch;
/**
* O token da sessão de serviço.
*
* Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
* })`) — e porque esconder um segredo atrás de um método privado não o
* torna menos segredo: o que protege é de onde ele veio.
*/
readonly token: string;/** Troca as credenciais do usuário de serviço por um token. */
static async autenticar(credenciais) {
const buscar = credenciais.buscar ?? globalThis.fetch;
const resposta = await buscar(`${credenciais.geohub}/auth/login`, {
body: JSON.stringify({
email: credenciais.email,
password: credenciais.senha,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!resposta.ok) throw new ErroDeAutenticacao(resposta.status);
const corpo = await resposta.json();
const token = corpo.data?.access_token;
if (token === undefined) throw new ErroDeAutenticacao(resposta.status);
return new SessaoDoIntegrador(
credenciais.geohub,
token,
credenciais.integradorId,
credenciais.buscar,
credenciais.esperar,
);
}
/** Quando o token já veio de outro lugar (um cofre, uma variável). */
static comToken(opcoes) {
return new SessaoDoIntegrador(
opcoes.geohub,
opcoes.token,
opcoes.integradorId,
opcoes.buscar,
opcoes.esperar,
);
}
/** A autoria que atravessa a rede para ser **conferida** contra a sessão. */
autoria;
/** O `fetch` desta sessão. É ele que o SDK recebe. */
buscar;
/**
* O token da sessão de serviço.
*
* Público porque é ele que o SDK recebe (`criarClienteOcorrencia({ token
* })`) — e porque esconder um segredo atrás de um método privado não o
* torna menos segredo: o que protege é de onde ele veio.
*/
token;curl -X POST https://geohub.exemplo.gov.br/auth/login -H "Content-Type: application/json" \
-d '{"email":"<usuário de serviço>","password":"…","mode":"json"}'
# {"data":{"access_token":"…","refresh_token":"…","expires":900000}}
# (ou o token estático do usuário de serviço, direto como Bearer)/**
* UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
*
* Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
* **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
* um índice que se lê em ordem de chegada.
*/
export function uuidV7(): string {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
const agora = Date.now();
for (let i = 0; i < 6; i += 1) {
bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
}
bytes[6] = ((bytes[6] as number) & 0x0f) | 0x70;
bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
}/**
* UUIDv7: 48 bits de milissegundos no começo, aleatório no resto.
*
* Versão 7 e não 4 porque o contrato publicado a exige, e porque ela é
* **ordenável no tempo** — a chave de idempotência vira, no banco do Geohub,
* um índice que se lê em ordem de chegada.
*/
export function uuidV7() {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
const agora = Date.now();
for (let i = 0; i < 6; i += 1) {
bytes[i] = Math.floor(agora / 256 ** (5 - i)) % 256;
}
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
}# Um UUID v7 por comando, cunhado antes da primeira tentativa:
curl -X POST … -H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f"/**
* Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
* chave** enquanto o comando não tem desfecho.
*
* Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
* `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
* o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
* repeti-la seria martelar quem já respondeu.
*/
async comando<T>(
executar: (solicitacaoId: string) => Promise<T>,
tentativas = 3,
): Promise<T> {
const solicitacaoId = uuidV7();
let ultima: unknown;
for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
try {
return await executar(solicitacaoId);
} catch (erro) {
const segundos = segundosSeEmAndamento(erro);
if (segundos !== undefined) {
await this.esperar(segundos * 1000);
} else if (!(erro instanceof TypeError)) {
throw erro;
}
ultima = erro;
}
}
throw ultima;
}/**
* Executa um comando com `solicitacaoId` próprio, repetindo **a mesma
* chave** enquanto o comando não tem desfecho.
*
* Dois casos, e só eles: a falha de rede (o pedido pode ter chegado) e o
* `409 SOLICITACAO_EM_ANDAMENTO` (chegou, e o primeiro ainda roda — espere
* o `Retry-After`). Qualquer outra resposta do servidor é desfecho, e
* repeti-la seria martelar quem já respondeu.
*/
async comando(executar, tentativas = 3) {
const solicitacaoId = uuidV7();
let ultima;
for (let tentativa = 1; tentativa <= tentativas; tentativa += 1) {
try {
return await executar(solicitacaoId);
} catch (erro) {
const segundos = segundosSeEmAndamento(erro);
if (segundos !== undefined) {
await this.esperar(segundos * 1000);
} else if (!(erro instanceof TypeError)) {
throw erro;
}
ultima = erro;
}
}
throw ultima;
}curl -X POST https://geohub.exemplo.gov.br/plugins/ocorrencias/$OCORRENCIA/despachos -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "x-solicitacao-id: 0199c0a1-7b2e-7c3d-8e4f-5a6b7c8d9e0f" \
-H 'x-autoria: {"tipo":"integrador","id":"'$INTEGRADOR'"}' \
-d '{"recurso":{"tipo":"guarnicao","guarnicaoId":"…","viatura":"RP-1207","composicao":[…],"identificador":"RP-1207"}}'
# {"dados":"<despachoId>"} (repetir a mesma chave devolve o mesmo, com "repetido":true)
# 409 {"erro":{"codigo":"SOLICITACAO_EM_ANDAMENTO"}} Retry-After: 2O exemplo declara portas no vocabulário do integrador e as implementa duas vezes, uma pelo SDK e outra por fetch; é por isso que o mesmo roteiro roda nos dois transportes sem um caso editado.
/** Os verbos de `jornada` que o papel `Integração - Sistema de despacho` usa. */
export interface PortaDeJornada {
assumirComando(dados: {
guarnicaoId: GuarnicaoId;
jornadaId: JornadaId;
}): Promise<Desfecho<void, FalhaDeJornada>>;
constituirGuarnicao(
dados: DadosDaGuarnicao,
): Promise<Desfecho<GuarnicaoId, FalhaDeJornada>>;
declararProntidao(
guarnicaoId: GuarnicaoId,
): Promise<Desfecho<void, FalhaDeJornada>>;
/** Leitura: `undefined` quando a viatura não tem guarnição `disponivel`. */
guarnicaoDisponivelNaViatura(
viatura: ViaturaId,
): Promise<GuarnicaoElegivel | undefined>;
iniciarJornada(dados: {
id: JornadaId;
posto: PostoOcupado;
}): Promise<Desfecho<void, FalhaDeJornada>>;
registrarApresentacao(
usuario: UsuarioId,
): Promise<Desfecho<JornadaId, FalhaDeJornada>>;
}
/** Os verbos de `ocorrencia` que o mesmo papel usa. */
export interface PortaDeOcorrencia {
atualizarOcorrencia(dados: {
alteracoes: AlteracoesDeOcorrencia;
id: OcorrenciaId;
}): Promise<Desfecho<void, FalhaDeOcorrencia>>;
despachar(dados: {
instrucoes?: string;
ocorrenciaId: OcorrenciaId;
recurso: RecursoGuarnicao;
}): Promise<Desfecho<DespachoId, FalhaDeOcorrencia>>;
registrarOcorrencia(
dados: DadosDeRegistroDeOcorrencia,
): Promise<Desfecho<OcorrenciaId, FalhaDeOcorrencia>>;
}// #endregion desfecho
// #endregion portas
export {};/**
* O desfecho de um comando.
*
* Falha de **negócio** é valor, não exceção: "a viatura não tem guarnição
* disponível" é uma resposta que o despachante trata na tela, não um
* incidente. Recusa de **protocolo** (token inválido, papel que não alcança
* o verbo, corpo fora do contrato) continua sendo exceção — ela é defeito
* do integrador, e engoli-la num `if` faria um bug de integração parecer
* uma regra de negócio.
*
* A falha é a do plugin, inteira: discriminada por `codigo`, com `contexto`
* só onde existe. Quem lê `codigo === 'referencia-externa-ja-registrada'`
* tem `contexto.ocorrenciaId` tipado, sem voltar ao `typeof`.
*/
export type Desfecho<T, F extends { readonly codigo: string }> =
| ({ readonly aceito: false } & F)
| { readonly aceito: true; readonly valor: T };// #endregion desfecho
// #endregion portas
export {};Pelo SDK, não há caminho nenhum escrito: o cliente tem os mesmos verbos do IJornadaHub e do IOcorrenciaHub. A vai na conexão (a exposição a confere contra a sessão) e a chave de é cunhada a cada comando; comSolicitacao(solicitacaoId) devolve os comandos presos a uma chave, que é como o retry repete a mesma.
/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao: SessaoDoIntegrador): PortaDeJornada {
const cliente = criarClienteJornada({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
/** Os comandos presos à chave de idempotência do comando: o retry a repete. */
const comando = (solicitacaoId: string) =>
cliente.comSolicitacao(solicitacaoId);
return {
assumirComando: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).assumirComando(dados)),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).constituirGuarnicao(dados)),
),
declararProntidao: (guarnicaoId) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
),
guarnicaoDisponivelNaViatura: (viatura) =>
cliente.guarnicaoDisponivelNaViatura(viatura),
iniciarJornada: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).iniciarJornada(dados)),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarApresentacao(usuario)),
),
};
}/** O plugin `jornada` pelo SDK. */
export function jornadaPorSdk(sessao) {
const cliente = criarClienteJornada({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
/** Os comandos presos à chave de idempotência do comando: o retry a repete. */
const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
return {
assumirComando: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).assumirComando(dados)),
),
constituirGuarnicao: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).constituirGuarnicao(dados)),
),
declararProntidao: (guarnicaoId) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).declararProntidao(guarnicaoId)),
),
guarnicaoDisponivelNaViatura: (viatura) =>
cliente.guarnicaoDisponivelNaViatura(viatura),
iniciarJornada: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).iniciarJornada(dados)),
),
registrarApresentacao: (usuario) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarApresentacao(usuario)),
),
};
}/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(
sessao: SessaoDoIntegrador,
): PortaDeOcorrencia {
const cliente = criarClienteOcorrencia({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
const comando = (solicitacaoId: string) =>
cliente.comSolicitacao(solicitacaoId);
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
),
despachar: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).despachar(dados)),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarOcorrencia(dados)),
),
};
}/** O plugin `ocorrencia` pelo SDK. */
export function ocorrenciaPorSdk(sessao) {
const cliente = criarClienteOcorrencia({
autoria: sessao.autoria,
buscar: sessao.buscar,
geohub: sessao.geohub,
token: sessao.token,
});
const comando = (solicitacaoId) => cliente.comSolicitacao(solicitacaoId);
return {
atualizarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).atualizarOcorrencia(dados)),
),
despachar: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).despachar(dados)),
),
registrarOcorrencia: (dados) =>
sessao.comando(async (id) =>
comoDesfecho(await comando(id).registrarOcorrencia(dados)),
),
};
}A falha de negócio já chega como Result, e a recusa de protocolo como exceção; só resta adaptar ao seu vocabulário.
/**
* O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
* dado, a falha leva a falha inteira — o `codigo` discriminado e o
* `contexto` de quem o tem.
*/
const comoDesfecho = <T, F extends { readonly codigo: string }>(
resultado: Result<T, F>,
): Desfecho<T, F> =>
resultado.success
? { aceito: true, valor: resultado.data }
: { aceito: false, ...resultado.error };/**
* O `Result` do plugin vira `Desfecho` sem perder nada: o sucesso leva o
* dado, a falha leva a falha inteira — o `codigo` discriminado e o
* `contexto` de quem o tem.
*/
const comoDesfecho = (resultado) =>
resultado.success
? { aceito: true, valor: resultado.data }
: { aceito: false, ...resultado.error };Os casos de uso do , com esse cliente, estão em registrar e despachar e entrar em serviço; o receptor de eventos, em o CAD recebe o que aconteceu.
Rodar os exemplos
pnpm --filter @geocontrol/exemplo-app-do-policial test
pnpm --filter @geocontrol/exemplo-integrador-despacho testAs duas suítes sobem o Geohub em processo: os dois plugins com a REST e a invocação geradas, os hubs em memória e, no integrador, o outbox e um emissor de de verdade. Não precisam de Docker nem de ambiente.