@opensas/xml-rest-adapter 0.2.1 → 0.2.3
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 +66 -11
- 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,72 @@ 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
|
+
// ORDER BY posicional ("_order=1" → columna 1 del select) — solo dígitos, no hay forma de
|
|
31
|
+
// convertir esto en nada más que un entero literal.
|
|
32
|
+
const POSICION_SQL = /^[0-9]+$/;
|
|
33
|
+
// count(*)/min(Columna)/max(Columna), con alias obligatorio — no es una apertura genérica a
|
|
34
|
+
// "cualquier función SQL": la lista de funciones es fija (las que consumidores reales necesitan
|
|
35
|
+
// hoy, ver docs/casos-de-uso-campos-orden.md) y el argumento solo puede ser '*' o un identificador
|
|
36
|
+
// bare — nada de subconsultas, operadores ni paréntesis anidados. Generaliza como grámatica
|
|
37
|
+
// verificable la excepción puntual que ya existía para el literal interno 'Count(*) as count'
|
|
38
|
+
// (ver comentario de execXmlConsulta en db.ts).
|
|
39
|
+
const AGREGADO_SQL = /^(count|min|max)\(\s*(\*|[A-Za-z_][A-Za-z0-9_]*)\s*\)\s+[A-Za-z_][A-Za-z0-9_]*$/i;
|
|
40
|
+
// Defensa en profundidad — espejo de dbo.esListaDeCampos (docker/fixture/scripts/02-support.sql,
|
|
41
|
+
// ver también docs/todos/sql-injection-campos-orden.md en convenios_web). La capa autoritativa es
|
|
42
|
+
// esa función en SQL Server: cubre también a los consumidores legacy (apps ASP clásico) que le
|
|
43
|
+
// pegan directo al SP sin pasar por acá. Esta validación solo evita el viaje a la base y da un
|
|
44
|
+
// 400 más claro cuando el caller es este adapter. No hace falta el caso especial de
|
|
45
|
+
// 'Count(*) as count' que sí tiene el lado SQL: ese literal lo arma execXmlConsulta (db.ts)
|
|
46
|
+
// server-side, en un punto que nunca lee _fields/_order de la URL.
|
|
47
|
+
function esListaDeCampos(lista, permiteAscDesc) {
|
|
48
|
+
if (!lista || lista.trim() === '')
|
|
49
|
+
return true;
|
|
50
|
+
if (lista === '*')
|
|
51
|
+
return true;
|
|
52
|
+
return lista.split(',').every((item) => {
|
|
53
|
+
const campo = item.trim();
|
|
54
|
+
if (campo === '')
|
|
55
|
+
return false;
|
|
56
|
+
if (AGREGADO_SQL.test(campo))
|
|
57
|
+
return true;
|
|
58
|
+
if (!permiteAscDesc)
|
|
59
|
+
return IDENTIFICADOR_SQL.test(campo);
|
|
60
|
+
const partes = campo.split(/\s+/);
|
|
61
|
+
if (partes.length > 2)
|
|
62
|
+
return false;
|
|
63
|
+
const [nombre, direccion] = partes;
|
|
64
|
+
if (!IDENTIFICADOR_SQL.test(nombre) && !POSICION_SQL.test(nombre))
|
|
65
|
+
return false;
|
|
66
|
+
return direccion === undefined || ['ASC', 'DESC'].includes(direccion.toUpperCase());
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// null = válido. String no vacío = mensaje de error (_fields/_order no son listas de columnas).
|
|
70
|
+
// Valida el _order ya traducido a la forma `Campo[,Campo][ DESC]` (post getXmlOrden) — el prefijo
|
|
71
|
+
// +/- de la URL no es lo que llega a la SP, así que no tiene sentido validar contra ese formato.
|
|
72
|
+
export function errorEnListaCampos(urlSearchParams) {
|
|
73
|
+
const campos = urlSearchParams.get(QUERY_KEYS.campos);
|
|
74
|
+
if (!esListaDeCampos(campos, false)) {
|
|
75
|
+
return `${QUERY_KEYS.campos} inválido: solo se admite una lista de nombres de columna separados por coma.`;
|
|
76
|
+
}
|
|
77
|
+
const ordenRaw = urlSearchParams.get(QUERY_KEYS.orden);
|
|
78
|
+
const orden = ordenRaw ? getXmlOrden(ordenRaw) : null;
|
|
79
|
+
if (!esListaDeCampos(orden, true)) {
|
|
80
|
+
return `${QUERY_KEYS.orden} inválido: solo se admite una lista de nombres de columna (con +/- opcional) separados por coma.`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
18
84
|
export function toXmlConsulta(urlSearchParams, app, objeto, operador) {
|
|
19
85
|
const metodoDefault = 'Consulta';
|
|
20
86
|
const metodo = urlSearchParams.get(QUERY_KEYS.metodo) || metodoDefault;
|
|
@@ -85,17 +151,6 @@ export function toXmlBaja(app, objeto, username, data = {}) {
|
|
|
85
151
|
Parametro: { Tabla: objeto, ...data },
|
|
86
152
|
});
|
|
87
153
|
}
|
|
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
154
|
const keysToCondition = (params) => {
|
|
100
155
|
const cond = [];
|
|
101
156
|
for (const [key, value] of params) {
|
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.3",
|
|
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
|
}
|