@opensas/xml-rest-adapter 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -0
- package/dist/index.js +1 -1
- package/dist/parse-request.js +8 -1
- package/dist/request-to-xml.d.ts +1 -0
- package/dist/request-to-xml.js +54 -11
- package/dist/valid.js +19 -1
- package/dist/xml.js +6 -0
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -8,3 +8,132 @@ npm: https://www.npmjs.com/package/@opensas/xml-rest-adapter
|
|
|
8
8
|
```bash
|
|
9
9
|
pnpm add @opensas/xml-rest-adapter
|
|
10
10
|
```
|
|
11
|
+
|
|
12
|
+
## Cómo funciona, paso a paso
|
|
13
|
+
|
|
14
|
+
`createXmlRestAdapter(app)` recibe la definición de una aplicación (nombre, cómo autenticar,
|
|
15
|
+
cómo autorizar, a qué base conectarse, qué objetos exponer) y devuelve dos funciones:
|
|
16
|
+
`resolveUser` y `handle`. No importa qué framework o runtime uses — ambas trabajan con `Request`/
|
|
17
|
+
`Response` de la Fetch API estándar, nada específico de SvelteKit ni de Node.
|
|
18
|
+
|
|
19
|
+
**1. Creá el adapter una sola vez**, con la config de tu app:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createXmlRestAdapter, endpoints } from '@opensas/xml-rest-adapter';
|
|
23
|
+
|
|
24
|
+
const adapter = createXmlRestAdapter({
|
|
25
|
+
name: 'MiApp', // tiene que coincidir con el Codigo de la app en Meta (o usá aplicacionCodigo)
|
|
26
|
+
authentication: { type: 'iis-node' }, // o { type: 'none' } para apps públicas/anónimas
|
|
27
|
+
authorization: { type: 'metaSSC', dbInfo: process.env.META_DATABASE_URL },
|
|
28
|
+
dbInfo: process.env.MI_APP_DATABASE_URL, // connection string ADO.NET, o un objeto DbInfo
|
|
29
|
+
pagination: 'sql', // 'com+' solo para SPs viejos sin @SqlPaginate
|
|
30
|
+
endpoints: endpoints(['Provincia', 'TipoDato']), // qué objetos expone esta app
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**2. Por cada request, resolvé quién está llamando** — `resolveUser` acepta el `Request` tal cual
|
|
35
|
+
(extrae la identidad según `authentication.type`) o un `string` ya resuelto:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const result = await adapter.resolveUser(request);
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
/* result.error: AppError con status/code/title — devolvelo como respuesta */
|
|
41
|
+
}
|
|
42
|
+
const usuario = result.data;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**3. Dejá que el adapter atienda el request** — `handle` necesita el `Request`, el segmento de
|
|
46
|
+
URL después de tu ruta (objeto, y opcionalmente `/id`), y el `usuario` del paso anterior:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const response = await adapter.handle(request, path, usuario);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Eso es todo — `handle` decide la acción (Consulta/Alta/Modificacion/Baja) según el método HTTP
|
|
53
|
+
(`GET`/`POST`/`PUT`/`DELETE`), valida permisos, arma y ejecuta el `xml{Objeto}_{Accion}` contra
|
|
54
|
+
SQL Server, y devuelve la `Response` ya lista (JSON o el error correspondiente).
|
|
55
|
+
|
|
56
|
+
## Ejemplo con SvelteKit
|
|
57
|
+
|
|
58
|
+
Un solo archivo alcanza — la carpeta **tiene** que ser `[...path]` (rest parameter), porque
|
|
59
|
+
`objeto`/`id` no son segmentos fijos:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// src/routes/mi-app/[...path]/+server.ts
|
|
63
|
+
import { createXmlRestAdapter, endpoints, jsonError } from '@opensas/xml-rest-adapter';
|
|
64
|
+
import { env } from '$env/dynamic/private';
|
|
65
|
+
import type { RequestHandler } from './$types.js';
|
|
66
|
+
|
|
67
|
+
const adapter = createXmlRestAdapter({
|
|
68
|
+
name: 'MiApp',
|
|
69
|
+
authentication: { type: 'iis-node' },
|
|
70
|
+
authorization: { type: 'metaSSC', dbInfo: env.META_DATABASE_URL },
|
|
71
|
+
dbInfo: env.MI_APP_DATABASE_URL,
|
|
72
|
+
pagination: 'sql',
|
|
73
|
+
endpoints: endpoints(['Provincia', 'TipoDato']),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Un solo handler para los 4 verbos — `fallback` los atrapa a todos, `.handle()` decide la
|
|
77
|
+
// acción según `event.request.method`.
|
|
78
|
+
export const fallback: RequestHandler = async (event) => {
|
|
79
|
+
const result = await adapter.resolveUser(event.request);
|
|
80
|
+
if (!result.ok) return jsonError(result.error);
|
|
81
|
+
return adapter.handle(event.request, event.params.path, result.data);
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Con esto, `GET /mi-app/Provincia?_limit=10`, `POST /mi-app/TipoDato`, `PUT /mi-app/Provincia/5`,
|
|
86
|
+
`DELETE /mi-app/Provincia/5` ya funcionan. Ejemplo completo y corrible en
|
|
87
|
+
[`examples/consumer-svelte/`](examples/consumer-svelte/).
|
|
88
|
+
|
|
89
|
+
## Ejemplo con Node puro (sin ningún framework)
|
|
90
|
+
|
|
91
|
+
`handle()`/`resolveUser()` no requieren nada de SvelteKit — alcanza con las `Request`/`Response`
|
|
92
|
+
globales que trae Node 18+. Un servidor mínimo con `node:http`:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { createServer } from 'node:http';
|
|
96
|
+
import { createXmlRestAdapter, endpoints, jsonError } from '@opensas/xml-rest-adapter';
|
|
97
|
+
|
|
98
|
+
const adapter = createXmlRestAdapter({
|
|
99
|
+
name: 'MiApp',
|
|
100
|
+
authentication: { type: 'none' }, // sin IIS: no hay identidad real que extraer del request
|
|
101
|
+
authorization: { type: 'none' },
|
|
102
|
+
dbInfo: process.env.MI_APP_DATABASE_URL,
|
|
103
|
+
pagination: 'sql',
|
|
104
|
+
endpoints: endpoints(['Provincia']),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const server = createServer(async (req, res) => {
|
|
108
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
109
|
+
const path = url.pathname.replace(/^\//, ''); // 'Provincia' o 'Provincia/5'
|
|
110
|
+
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
111
|
+
|
|
112
|
+
const request = new Request(url, {
|
|
113
|
+
method: req.method,
|
|
114
|
+
headers: req.headers,
|
|
115
|
+
body: hasBody ? req : undefined,
|
|
116
|
+
duplex: hasBody ? 'half' : undefined,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const userResult = await adapter.resolveUser(request);
|
|
120
|
+
const response = userResult.ok
|
|
121
|
+
? await adapter.handle(request, path, userResult.data)
|
|
122
|
+
: jsonError(userResult.error);
|
|
123
|
+
|
|
124
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
125
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.listen(3000);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Prueba mínima sin siquiera levantar un servidor HTTP (construyendo los `Request` a mano y
|
|
132
|
+
llamando a `handle()` directo) en [`examples/consumer/test.mjs`](examples/consumer/test.mjs).
|
|
133
|
+
|
|
134
|
+
## Más
|
|
135
|
+
|
|
136
|
+
- [`docs/uso-interno.md`](docs/uso-interno.md) — gotchas y detalles que no son obvios a la
|
|
137
|
+
primera (qué campo tiene que matchear con Meta, cómo mapea `endpoints`/URL/SP, etc.).
|
|
138
|
+
- [`docs/proximos-pasos.md`](docs/proximos-pasos.md) — el porqué de cada decisión de diseño.
|
|
139
|
+
- [`CHANGELOG.md`](CHANGELOG.md) — qué cambió entre versiones.
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// export const fallback: RequestHandler = async (event) => {
|
|
9
9
|
// const result = await adapter.resolveUser(event.request);
|
|
10
10
|
// if (!result.ok) return jsonError(result.error);
|
|
11
|
-
// return adapter.handle(event, result.data);
|
|
11
|
+
// return adapter.handle(event.request, event.params.path, result.data);
|
|
12
12
|
// };
|
|
13
13
|
export { createXmlRestAdapter } from './create-adapter.js';
|
|
14
14
|
export { endpoints } from './endpoints.js';
|
package/dist/parse-request.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// nada contra la base — deja el XmlSentencia listo para pasarle a execXmlSentencia/
|
|
6
6
|
// execXmlConsulta.
|
|
7
7
|
import { appError } from './errors.js';
|
|
8
|
-
import { toXmlAlta, toXmlBaja, toXmlConsulta, toXmlConsultaId, toXmlModificacion, } from './request-to-xml.js';
|
|
8
|
+
import { errorEnListaCampos, toXmlAlta, toXmlBaja, toXmlConsulta, toXmlConsultaId, toXmlModificacion, } from './request-to-xml.js';
|
|
9
9
|
import { err, ok } from './result.js';
|
|
10
10
|
import { checkPermiso, validParams } from './valid.js';
|
|
11
11
|
const MULTIPART_CONTENT_TYPE = 'multipart/form-data';
|
|
@@ -55,6 +55,13 @@ export async function parseRequest(app, request, path, usuario) {
|
|
|
55
55
|
const operador = usuario.domainUser;
|
|
56
56
|
const url = new URL(request.url);
|
|
57
57
|
if (metodo === 'GET') {
|
|
58
|
+
// toXmlConsultaId no lee _fields/_order de la URL (Campos/Orden fijos, ver
|
|
59
|
+
// request-to-xml.ts) — la validación solo aplica al listado.
|
|
60
|
+
if (!id) {
|
|
61
|
+
const error = errorEnListaCampos(url.searchParams);
|
|
62
|
+
if (error)
|
|
63
|
+
return err(appError(error, 'VALIDATION_ERROR'));
|
|
64
|
+
}
|
|
58
65
|
const sentencia = id
|
|
59
66
|
? toXmlConsultaId(url.searchParams, app, objeto, id, operador, fieldId)
|
|
60
67
|
: toXmlConsulta(url.searchParams, app, objeto, operador);
|
package/dist/request-to-xml.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Aplicacion } from './types.js';
|
|
2
2
|
import { type XmlSentencia } from './xml.js';
|
|
3
|
+
export declare function errorEnListaCampos(urlSearchParams: URLSearchParams): string | null;
|
|
3
4
|
export declare function toXmlConsulta(urlSearchParams: URLSearchParams, app: Aplicacion, objeto: string, operador: string): XmlSentencia;
|
|
4
5
|
export declare function toXmlConsultaId(urlSearchParams: URLSearchParams, app: Aplicacion, objeto: string, id: number, username: string, fieldId?: string): XmlSentencia;
|
|
5
6
|
export declare function toXmlAlta(app: Aplicacion, objeto: string, username: string, data: Record<string, string>): XmlSentencia;
|
package/dist/request-to-xml.js
CHANGED
|
@@ -15,6 +15,60 @@ const QUERY_KEYS = {
|
|
|
15
15
|
campos: '_fields',
|
|
16
16
|
orden: '_order',
|
|
17
17
|
};
|
|
18
|
+
const getXmlOrden = (orden) => orden
|
|
19
|
+
.split(',')
|
|
20
|
+
.map((field) => field.trim())
|
|
21
|
+
.map((field) => {
|
|
22
|
+
if (field.startsWith('-'))
|
|
23
|
+
return field.slice(1) + ' DESC';
|
|
24
|
+
if (field.startsWith('+'))
|
|
25
|
+
return field.slice(1);
|
|
26
|
+
return field;
|
|
27
|
+
})
|
|
28
|
+
.join(',');
|
|
29
|
+
const IDENTIFICADOR_SQL = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
30
|
+
// Defensa en profundidad — espejo de dbo.esListaDeCampos (docker/fixture/scripts/02-support.sql,
|
|
31
|
+
// ver también docs/todos/sql-injection-campos-orden.md en convenios_web). La capa autoritativa es
|
|
32
|
+
// esa función en SQL Server: cubre también a los consumidores legacy (apps ASP clásico) que le
|
|
33
|
+
// pegan directo al SP sin pasar por acá. Esta validación solo evita el viaje a la base y da un
|
|
34
|
+
// 400 más claro cuando el caller es este adapter. No hace falta el caso especial de
|
|
35
|
+
// 'Count(*) as count' que sí tiene el lado SQL: ese literal lo arma execXmlConsulta (db.ts)
|
|
36
|
+
// server-side, en un punto que nunca lee _fields/_order de la URL.
|
|
37
|
+
function esListaDeCampos(lista, permiteAscDesc) {
|
|
38
|
+
if (!lista || lista.trim() === '')
|
|
39
|
+
return true;
|
|
40
|
+
if (lista === '*')
|
|
41
|
+
return true;
|
|
42
|
+
return lista.split(',').every((item) => {
|
|
43
|
+
const campo = item.trim();
|
|
44
|
+
if (campo === '')
|
|
45
|
+
return false;
|
|
46
|
+
if (!permiteAscDesc)
|
|
47
|
+
return IDENTIFICADOR_SQL.test(campo);
|
|
48
|
+
const partes = campo.split(/\s+/);
|
|
49
|
+
if (partes.length > 2)
|
|
50
|
+
return false;
|
|
51
|
+
const [nombre, direccion] = partes;
|
|
52
|
+
if (!IDENTIFICADOR_SQL.test(nombre))
|
|
53
|
+
return false;
|
|
54
|
+
return direccion === undefined || ['ASC', 'DESC'].includes(direccion.toUpperCase());
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// null = válido. String no vacío = mensaje de error (_fields/_order no son listas de columnas).
|
|
58
|
+
// Valida el _order ya traducido a la forma `Campo[,Campo][ DESC]` (post getXmlOrden) — el prefijo
|
|
59
|
+
// +/- de la URL no es lo que llega a la SP, así que no tiene sentido validar contra ese formato.
|
|
60
|
+
export function errorEnListaCampos(urlSearchParams) {
|
|
61
|
+
const campos = urlSearchParams.get(QUERY_KEYS.campos);
|
|
62
|
+
if (!esListaDeCampos(campos, false)) {
|
|
63
|
+
return `${QUERY_KEYS.campos} inválido: solo se admite una lista de nombres de columna separados por coma.`;
|
|
64
|
+
}
|
|
65
|
+
const ordenRaw = urlSearchParams.get(QUERY_KEYS.orden);
|
|
66
|
+
const orden = ordenRaw ? getXmlOrden(ordenRaw) : null;
|
|
67
|
+
if (!esListaDeCampos(orden, true)) {
|
|
68
|
+
return `${QUERY_KEYS.orden} inválido: solo se admite una lista de nombres de columna (con +/- opcional) separados por coma.`;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
18
72
|
export function toXmlConsulta(urlSearchParams, app, objeto, operador) {
|
|
19
73
|
const metodoDefault = 'Consulta';
|
|
20
74
|
const metodo = urlSearchParams.get(QUERY_KEYS.metodo) || metodoDefault;
|
|
@@ -85,17 +139,6 @@ export function toXmlBaja(app, objeto, username, data = {}) {
|
|
|
85
139
|
Parametro: { Tabla: objeto, ...data },
|
|
86
140
|
});
|
|
87
141
|
}
|
|
88
|
-
const getXmlOrden = (orden) => orden
|
|
89
|
-
.split(',')
|
|
90
|
-
.map((field) => field.trim())
|
|
91
|
-
.map((field) => {
|
|
92
|
-
if (field.startsWith('-'))
|
|
93
|
-
return field.slice(1) + ' DESC';
|
|
94
|
-
if (field.startsWith('+'))
|
|
95
|
-
return field.slice(1);
|
|
96
|
-
return field;
|
|
97
|
-
})
|
|
98
|
-
.join(',');
|
|
99
142
|
const keysToCondition = (params) => {
|
|
100
143
|
const cond = [];
|
|
101
144
|
for (const [key, value] of params) {
|
package/dist/valid.js
CHANGED
|
@@ -23,7 +23,25 @@ function toPascalCase(str) {
|
|
|
23
23
|
})
|
|
24
24
|
.join('');
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// Match exacto primero. Si falla y el segmento tiene un separador `_` explícito, reintenta
|
|
27
|
+
// case-insensitive: un `Endpoint.name` con un acrónimo en mayúsculas (`ActividadCIIU`,
|
|
28
|
+
// `CategoriaCIIU`) nunca coincide con lo que arma `toPascalCase` a partir del segmento de URL
|
|
29
|
+
// (`actividad_ciiu` → `ActividadCiiu`, con "Ciiu" no "CIIU") — ningún algoritmo genérico de
|
|
30
|
+
// snake_case→PascalCase puede adivinar qué partes de un nombre compuesto son sigla.
|
|
31
|
+
// convenios_web nunca tuvo este problema porque su comparación (`isValidObjeto`) ya era
|
|
32
|
+
// case-insensitive — portado acá, pero acotado al caso con separador: sin `_` en el segmento
|
|
33
|
+
// mantenemos el exacto a propósito (`tipodato`, sin límite de palabra explícito, sigue sin
|
|
34
|
+
// matchear `TipoDato` — ver custom-url-casing.fixture.test.ts, cualquier coincidencia ahí sería
|
|
35
|
+
// casualidad, no una reconstrucción real). Encontrado migrando `CctDigitalizacion` en
|
|
36
|
+
// convenios_web (ver docs/todos/rest-baseline-pre-migracion.md).
|
|
37
|
+
const getEndpoint = (app, objeto) => {
|
|
38
|
+
const pascal = toPascalCase(objeto);
|
|
39
|
+
const exact = app.endpoints.find((ep) => ep.name === pascal);
|
|
40
|
+
if (exact || !objeto.includes('_'))
|
|
41
|
+
return exact;
|
|
42
|
+
const normalized = pascal.toLowerCase();
|
|
43
|
+
return app.endpoints.find((ep) => ep.name.toLowerCase() === normalized);
|
|
44
|
+
};
|
|
27
45
|
export function validParams(app, path, withId = 'noId') {
|
|
28
46
|
const [objetoRaw, idRaw] = path.split('/');
|
|
29
47
|
if (!objetoRaw)
|
package/dist/xml.js
CHANGED
|
@@ -28,6 +28,12 @@ export function xmlConsulta(options) {
|
|
|
28
28
|
Campos: options.Parametro?.Campos || '*',
|
|
29
29
|
Orden: options.Parametro?.Orden,
|
|
30
30
|
Condiciones: options.Parametro?.Condiciones,
|
|
31
|
+
// Lista fija a propósito, no spread de options.Parametro: agregar un campo nuevo acá
|
|
32
|
+
// es una decisión explícita, no "lo que sea que venga en el objeto" — evita que una
|
|
33
|
+
// key con otro nombre en el futuro se cuele sin que nadie la haya revisado. ShowSql
|
|
34
|
+
// (bit de xmlConsulta_Parse/ixmlTabla_Consulta, ver docker/fixture/scripts/02-support.sql)
|
|
35
|
+
// devuelve el SQL armado sin ejecutarlo — lo usan las pruebas de sql-injection.fixture.test.ts.
|
|
36
|
+
ShowSql: options.Parametro?.ShowSql,
|
|
31
37
|
},
|
|
32
38
|
});
|
|
33
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensas/xml-rest-adapter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -57,37 +57,37 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@eslint/js": "^10.0.1",
|
|
60
|
-
"@playwright/test": "^1.
|
|
60
|
+
"@playwright/test": "^1.63.0",
|
|
61
61
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
62
62
|
"@sveltejs/kit": "^2.63.0",
|
|
63
63
|
"@sveltejs/package": "^2.5.8",
|
|
64
64
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
65
65
|
"@types/mssql": "^12.3.0",
|
|
66
|
-
"@types/node": "^24",
|
|
66
|
+
"@types/node": "^24.13.5",
|
|
67
67
|
"dotenv": "^17.4.2",
|
|
68
|
-
"eslint": "^10.
|
|
68
|
+
"eslint": "^10.10.0",
|
|
69
69
|
"eslint-config-prettier": "^10.1.8",
|
|
70
70
|
"eslint-plugin-check-file": "^3.3.2",
|
|
71
71
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
|
72
72
|
"eslint-plugin-svelte": "^3.19.0",
|
|
73
73
|
"eslint-plugin-unicorn": "^71.1.0",
|
|
74
|
-
"globals": "^17.
|
|
74
|
+
"globals": "^17.12.0",
|
|
75
75
|
"jiti": "^2.7.0",
|
|
76
|
-
"prettier": "^3.
|
|
76
|
+
"prettier": "^3.9.7",
|
|
77
77
|
"prettier-plugin-svelte": "^4.1.0",
|
|
78
78
|
"publint": "^0.3.21",
|
|
79
|
-
"svelte": "^5.
|
|
79
|
+
"svelte": "^5.57.0",
|
|
80
80
|
"svelte-check": "^4.6.0",
|
|
81
|
-
"tsx": "^4.23.
|
|
81
|
+
"tsx": "^4.23.13",
|
|
82
82
|
"typescript": "^6.0.3",
|
|
83
|
-
"typescript-eslint": "^8.
|
|
84
|
-
"vite": "^8.0
|
|
83
|
+
"typescript-eslint": "^8.70.0",
|
|
84
|
+
"vite": "^8.3.0",
|
|
85
85
|
"vitest": "^4.1.8"
|
|
86
86
|
},
|
|
87
87
|
"keywords": [
|
|
88
88
|
"svelte"
|
|
89
89
|
],
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"mssql": "^12.7.
|
|
91
|
+
"mssql": "^12.7.2"
|
|
92
92
|
}
|
|
93
93
|
}
|