@opensas/xml-rest-adapter 0.2.1 → 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 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';
@@ -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);
@@ -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;
@@ -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/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.1",
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.62.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.4.1",
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.6.0",
74
+ "globals": "^17.12.0",
75
75
  "jiti": "^2.7.0",
76
- "prettier": "^3.8.3",
76
+ "prettier": "^3.9.7",
77
77
  "prettier-plugin-svelte": "^4.1.0",
78
78
  "publint": "^0.3.21",
79
- "svelte": "^5.56.1",
79
+ "svelte": "^5.57.0",
80
80
  "svelte-check": "^4.6.0",
81
- "tsx": "^4.23.12",
81
+ "tsx": "^4.23.13",
82
82
  "typescript": "^6.0.3",
83
- "typescript-eslint": "^8.60.1",
84
- "vite": "^8.0.16",
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.0"
91
+ "mssql": "^12.7.2"
92
92
  }
93
93
  }