@cosmospro/msw-odata
v1.3.2
Published
Mock de APIs OData (v4 e v2) sobre MSW: CRUD com $filter/$orderby/$top/$skip/$select/$count/$expand/$apply, autenticação Bearer e bound/unbound actions e functions com paginação delegada.
Maintainers
Readme
@cosmospro/msw-odata
Biblioteca para mockar endpoints OData dentro do MSW (Mock Service Worker) — análoga ao helper graphql.* que o MSW oferece para GraphQL, porém voltada a APIs OData (v4 e v2). Oferece uma API fluent que, em poucas linhas, gera handlers MSW cobrindo todos os verbos HTTP (GET, POST, PUT, PATCH, DELETE, MERGE) sobre uma coleção, com suporte a autenticação, envelope OData real e store in-memory com reset.
Framework-agnostic — funciona em projetos React, Angular, Vue, Svelte ou Vanilla JS.
🚀 Instalação
npm install --save-dev @cosmospro/msw-odata mswRequisitos: Node.js ≥ 18, MSW ≥ 2.0
⚡ Início rápido — API fluent
A forma recomendada de usar a lib. Um único método (mockOData) gera handlers MSW cobrindo todos os verbos HTTP sobre a coleção:
import { setupWorker } from 'msw/browser'
import { mockOData } from '@cosmospro/msw-odata'
const users = [
{ id: '1', name: 'João Silva', status: 'active' },
{ id: '2', name: 'Maria Santos', status: 'inactive' },
]
export const worker = setupWorker(
...mockOData('/api/users', users).handlers(),
)Com essas três linhas você ganha:
| Rota | Verbo | Comportamento |
|---------------------|---------|--------------------------------------------------------------|
| /api/users | GET | Lista com $filter, $orderby, $top, $skip, $select, $count |
| /api/users | POST | Insere no store, retorna 201 |
| /api/users(:key) | GET | Retorna item ou 404 |
| /api/users(:key) | PUT | Replace completo, 200 |
| /api/users(:key) | PATCH | Merge parcial, 200 |
| /api/users(:key) | MERGE | Alias de PATCH (OData v2 legacy) |
| /api/users(:key) | DELETE | Remove do store, 204 |
E, opcionalmente, bound/unbound operations (actions e functions OData v4) — veja a seção Bound & Unbound Operations:
| Rota | Verbo | Registro |
|-----------------------------------|--------|-----------------------------------------|
| /api/users(:key)/Action | POST | .onAction('Action', fn) |
| /api/users(:key)/Function | GET | .onFunction('Function', fn) |
| /api/users/CollectionAction | POST | .onCollectionAction('CollectionAction', fn) |
| /api/users/CollectionFn | GET | .onCollectionFunction('CollectionFn', fn) |
Resposta de GET /api/users:
{
"@odata.context": "$metadata#users",
"@odata.count": 2,
"value": [
{ "id": "1", "name": "João Silva", "status": "active" },
{ "id": "2", "name": "Maria Santos", "status": "inactive" }
]
}🔐 Autenticação — Bearer token
mockOData('/api/users', users)
.withAuth.bearer('secret-token-123')
.handlers()Requests sem o header Authorization: Bearer secret-token-123 recebem 401 automaticamente. Você também pode passar uma função validadora:
mockOData('/api/users', users)
.withAuth.bearer((token) => token.startsWith('prefix_'))
.handlers()Para predicados totalmente customizados (qualquer header, cookie, etc.):
mockOData('/api/users', users)
.withAuth((request) => request.headers.get('x-api-key') === 'abc')
.handlers()🎯 Override por verbo — on*
Intercepte um verbo específico mantendo o comportamento default nos demais. Use ctx.next() para delegar à implementação padrão:
mockOData('/api/users', users)
.onPost(async (ctx) => {
const body = await ctx.request.clone().json()
if (!body.email) return ctx.reply.badRequest('email required')
return ctx.next() // delega ao POST default
})
.onDelete((ctx) => ctx.reply.forbidden()) // nunca permite delete
.handlers()O objeto ctx recebido por cada override contém:
| Campo | Tipo | Descrição |
|-------------|-------------------|---------------------------------------------------------|
| request | Request | Request original do fetch |
| verb | HttpVerb | Verbo HTTP detectado |
| key | EntityKey? | Presente em rotas /:key (ex: GET /users(1)) |
| query | Record<string,string> | Params da query string |
| store | StoreAdapter<T> | Acesso direto ao store (list/getByKey/insert/...) |
| reply | ReplyHelpers | Atalhos: json, ok, created, noContent, notFound, unauthorized, forbidden, badRequest |
| next() | () => Promise<Response> | Delega ao handler default da lib |
⚙️ Bound & Unbound Operations (actions / functions)
OData v4 define dois tipos de operação fora do CRUD:
- Action — POST com side effect (spec §11.5).
- Function — operação idempotente, normalmente GET.
Ambas podem ser bound (vinculadas a uma entidade, URL /Set(key)/Name) ou unbound (vinculadas à coleção, URL /Set/Name). A lib expõe quatro primitivas — todas integram automaticamente com withAuth, store e o engine de paginação:
| Builder method | URL gerada | Verbo | Quando usar |
|--------------------------------------|----------------------------------|---------------|----------------------------------------------------------------------|
| .onAction(name, fn) | POST /Set(key)/Name | POST | Side effect sobre uma entidade |
| .onFunction(name, fn) | GET /Set(key)/Name | GET (default) | Consulta sobre uma entidade (override method: 'POST' opcional) |
| .onCollectionAction(name, fn) | POST /Set/Name | POST | Side effect em massa (payload com ids, etc.) |
| .onCollectionFunction(name, fn) | GET /Set/Name | GET (default) | Subset filtrado paginável (override method: 'POST' p/ DevExtreme) |
mockOData('/odata/Workflow/WorkFlows', workflows)
// bound action: muta uma entidade
.onAction('Aprove', (ctx) => {
if (!ctx.store.getByKey(ctx.key)) return ctx.reply.notFound()
ctx.store.update(ctx.key, { Status: 'Aproved' })
return ctx.reply.noContent()
})
// bound function: devolve subset paginável relacionado
.onFunction('RelatedComments', (ctx) => {
const items = ALL_COMMENTS.filter((c) => c.WorkflowId === ctx.key)
return ctx.reply.collection(items) // $top/$skip/$count aplicam aqui
})
// unbound action: payload com ids
.onCollectionAction('AproveAll', async (ctx) => {
const { ids } = await ctx.request.json()
ids.forEach((id) => ctx.store.update(id, { Status: 'Aproved' }))
return ctx.reply.ok({ updated: ids.length })
})
// unbound function (DevExtreme força POST via beforeSend)
.onCollectionFunction('Pending', { method: 'POST' }, (ctx) =>
ctx.reply.collection(ctx.store.list().filter((w) => w.Type === 'Pending'))
)
.handlers()ctx.reply.collection(items, opts?)
Renderiza um subset usando o engine padrão — aplica $top, $skip, $count, $orderby, $filter, $select e $expand da query string sobre os itens informados, e devolve no envelope OData v4/v2 correto. Sem isso, toda function de coleção reimplementaria paginação.
.onCollectionFunction('Active', (ctx) =>
ctx.reply.collection(ctx.store.list().filter((w) => w.IsActive))
)
// GET /Set/Active?$top=10&$skip=20&$count=true&$orderby=name desc
// → engine padrão aplica os system query options sobre o subsetPara devolver o subset cru (sem paginação ou filtro), passe { applyQuery: false }.
Contexto do handler
Bound operations recebem ctx.key garantido (não-opcional). Em chave composta (/Set(Id=1,OrgId=2)/Action), ctx.key chega como Record<string, string | number>.
| Campo | Tipo | Disponível em |
|------------------|-------------------------|----------------------|
| request | Request | bound + unbound |
| url | URL | bound + unbound |
| query | Record<string,string> | bound + unbound |
| store | StoreAdapter<T> | bound + unbound |
| reply | ActionReplyHelpers<T> | bound + unbound (estende ReplyHelpers com collection) |
| operationName | string | bound + unbound |
| key | EntityKey | bound only |
Detecção de conflitos
A lib valida nomes no momento do registro e detecta duplicatas em handlers():
- Throw imediato — nome reservado pela spec (
$count,$value,$batch, ...), começando com$, ou contendo/,?,(, espaços, etc. - Conflito de duplicata — mesma
(name, bound)registrada duas vezes. Comportamento configurável viaconflicts:
mockOData('/api/users', users, {
conflicts: 'throw' // default — recomendado para dev/CI
// | 'warn' // console.warn e ignora a duplicata (a primeira vence)
// | 'silent' // não reporta nada
})Bound e unbound com mesmo nome não conflitam (URLs distintas).
🚫 Desabilitando verbos
mockOData('/api/users', users).readonly().handlers() // apenas GET
mockOData('/api/users', users).disable('DELETE', 'PUT').handlers() // sem delete/putVerbos desabilitados retornam 405 Method Not Allowed.
🧩 Múltiplas collections — composeHandlers
import { mockOData, composeHandlers, authBearer } from '@cosmospro/msw-odata'
const auth = authBearer('secret')
export const handlers = composeHandlers(
mockOData('/api/users', users).withAuth(auth),
mockOData('/api/orders', orders).withAuth(auth),
mockOData('/api/products', products).readonly(),
)composeHandlers aceita builders, handlers MSW crus (http.get(...)) ou arrays deles — tudo é achatado em um único array pronto para setupWorker/setupServer.
🧪 Reset do store em testes
A lib clona o seed (via structuredClone) e trabalha numa cópia interna — seu array original nunca é mutado. Para voltar ao estado inicial entre testes:
const mock = mockOData('/api/users', users)
const server = setupServer(...mock.handlers())
beforeEach(() => {
mock.reset() // volta ao clone do seed
})⚙️ Opções
mockOData('/api/users', users, {
key: 'userId', // campo de chave primária (default: 'id')
version: 'v2', // 'v4' (default) ou 'v2'
alwaysIncludeCount: true, // força $count=true em GET list (default: false)
})Envelope OData v4 (default)
{
"@odata.context": "$metadata#users",
"@odata.count": 2,
"value": [ ... ]
}Envelope OData v2 — version: 'v2'
{
"d": {
"results": [ ... ],
"__count": "2"
}
}📋 Operadores OData suportados no $filter
| Operador / Função | Exemplo | Descrição |
|-------------------|------------------------------------------------|----------------------------------|
| eq | status eq 'active' | Igualdade (case-insensitive) |
| contains | contains(name,'João') | Contém substring |
| startswith | startswith(name,'Jo') | Começa com |
| endswith | endswith(email,'.com') | Termina com |
| and | contains(name,'Jo') and status eq 'active' | Conjunção lógica |
| or | status eq 'active' or status eq 'pending' | Disjunção lógica |
| not | not status eq 'inactive' | Negação lógica |
Campos aninhados via notação de ponto: customer.name eq 'João'.
Demais system query options suportadas: $orderby, $top, $skip, $select, $count.
🚧 Restringindo capabilities de $filter
Servidores OData reais raramente implementam 100% da spec — alguns suportam só ~90% das funções de $filter. Sem restrição, um teste contra o mock pode passar usando contains() e quebrar em produção, num backend que não implementa essa função.
A lib aceita uma config opt-in que declara quais operadores e funções o mock aceita. Quando uma query usar algo fora do permitido, o handler responde 400 Bad Request com mensagem identificando o token rejeitado — espelhando o backend real:
// Allowlist — apenas os listados passam
mockOData('/api/users', users, {
filter: { allow: ['eq', 'ne', 'and', 'or'] },
})
// Denylist — todos exceto os listados passam
mockOData('/api/users', users, {
filter: { deny: ['contains', 'startswith'] },
})Exemplo de resposta quando a query usa um token bloqueado:
GET /api/users?$filter=startswith(name,'Jo')
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"error": {
"code": "400",
"message": "Filter operator/function 'startswith' is not supported by the configured capabilities"
}
}Tokens disponíveis
| Categoria | Tokens |
|-------------|-------------------------------------------------------------------------------------|
| Comparação | eq, ne, gt, ge, lt, le, in, has |
| Lógica | and, or, not |
| Aritmética | add, sub, mul, div, mod |
| String | contains, startswith, endswith, length, indexof, substring, tolower, toupper, trim, concat |
| Data | year, month, day, hour, minute, second, now, date, time |
| Math | round, floor, ceiling |
Regras
- Default: sem
filterconfigurado, tudo é aceito (backwards-compatible). - Allow vs deny: passar
allowedenyjuntos lançaODataFilterCapabilityConfigErrorna chamada demockOData(...)— escolha um. - Allowlist é mais segura: denylists são incompletas por natureza. Se o parser ganhar suporte a um operador novo no futuro e ele não estiver na sua denylist, a query passa silenciosa.
- Nó estrutural (parênteses de agrupamento) sempre passa, sem exigir token na config.
Uso direto via applyODataQuery
A mesma config é aceita pela API legada (4º argumento opcional):
applyODataQuery(
"?$filter=status eq 'active'",
users,
undefined, // navigationProperties (opcional)
{ filter: { allow: ['eq', 'and'] } }, // capabilities
)
// → throw ODataFilterCapabilityError se a query usar token fora do allow🏗️ Estrutura do pacote
src/
├── index.ts # barrel público
├── types.ts # interfaces públicas
├── odataUtils.ts # shim de backwards-compat (re-exporta odata/engine)
├── odata/ # engine puro (sem dependência de MSW)
│ ├── engine.ts # applyODataQuery, createODataHandlerResponse
│ ├── parser.ts # parsing das query strings OData
│ ├── filter.ts # avaliação do AST de $filter
│ ├── orderby.ts # $orderby
│ └── select.ts # $select + helpers de campos aninhados
├── builder/ # fluent API sobre MSW
│ ├── mockOData.ts # builder principal
│ ├── defaultHandlers.ts # implementações default dos 7 verbos
│ ├── context.ts # HandlerContext + ReplyHelpers + ActionReplyHelpers
│ ├── envelope.ts # envelopes OData v4 e v2
│ ├── keyParser.ts # parse de /Entity(1), /Entity('abc'), /Entity(k=1,k2=2)
│ ├── operations.ts # registry + validação de bound/unbound actions e functions
│ └── compose.ts # composeHandlers
├── auth/
│ ├── bearer.ts # authBearer
│ └── guard.ts # middleware interno, 401 automático
├── store/
│ └── InMemoryStore.ts # store com Map + structuredClone + reset
└── tests/ # suíte de testes (Vitest)🧱 Low-level API (legada, ainda suportada)
Se você já tem handlers MSW próprios e só quer o motor de processamento OData, as duas funções originais continuam exportadas e funcionam igual à v1.0:
applyODataQuery<T>(queryString, collection)
import { applyODataQuery } from '@cosmospro/msw-odata'
const result = applyODataQuery(
"?$filter=status eq 'active'&$top=1&$count=true",
collection,
)
// result.value → dados filtrados
// result.count → total pré-paginação (quando $count=true)
// result.hasNext → indica próxima páginacreateODataHandlerResponse<T>(queryString, collection, includeCount?)
Wrapper sobre applyODataQuery que força $count=true por padrão e retorna { data, total, hasNext } — útil se você mantém um formato de envelope customizado:
import { http, HttpResponse } from 'msw'
import { createODataHandlerResponse } from '@cosmospro/msw-odata'
http.get('/api/users', ({ request }) => {
const queryString = new URL(request.url).search
const { data, total, hasNext } = createODataHandlerResponse(queryString, users)
return HttpResponse.json({ '@odata.count': total, value: data, hasNext })
})🛠️ Desenvolvimento
| Comando | Descrição |
|----------------------|------------------------------------------------|
| npm run build | Compila para CJS e ESM com tipagem (tsup) |
| npm run dev | Modo watch |
| npm run typecheck | Verificação de tipos sem emitir arquivos |
| npm run test | Executa todos os testes (Vitest) |
| npm run test:watch | Testes em modo watch |
| npm run test:ui | Abre a UI do Vitest em http://localhost:51204/__vitest__/ |
📄 Licença
MIT — Parte do projeto CosmosPro.
