@jvaarala/vm-buketti 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jesse Väärälä
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ DATA LICENSE NOTICE
26
+
27
+ The data retrieved through this library originates from budjetti.vm.fi and is
28
+ published by the Ministry of Finance of Finland as part of the open government
29
+ data programme. The data is licensed under the Creative Commons Attribution 4.0
30
+ International license (CC BY 4.0):
31
+
32
+ http://creativecommons.org/licenses/by/4.0/
33
+
34
+ Users of this library must comply with the CC BY 4.0 terms when using the data,
35
+ including crediting the Ministry of Finance of Finland as the source.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # vm-buketti
2
+
3
+ TypeScript client for the Finnish state budget open data API at [budjetti.vm.fi](https://budjetti.vm.fi).
4
+
5
+ Turns the XML-based API into a lazy-loading tree you can navigate by awaiting properties — no manual XML parsing, no upfront fetches.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @jvaarala/vm-buketti
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { createVmBuketti } from '@jvaarala/vm-buketti';
17
+
18
+ // Node 18+ (direct fetch — no proxy needed):
19
+ const service = createVmBuketti();
20
+
21
+ // Browser (requires a CORS proxy):
22
+ const service = createVmBuketti({ proxyUrl: '/proxy' });
23
+
24
+ // Navigate the budget tree
25
+ const juuri = service.getJuuri();
26
+ const vuodet = await juuri.Vuosi; // all budget years
27
+ const teos = vuodet[0].Teos[0]; // first publication (e.g. tae)
28
+ const kanta = teos.Kanta[0]; // budget version
29
+ const pkList = await kanta.Paaluokka; // expense main classes (lazy fetch)
30
+ const menot = await pkList[0].Menoluku; // chapters within first main class
31
+ ```
32
+
33
+ Data is fetched on demand. Accessing a property for the first time fires the corresponding XML request; repeated accesses return the cached Promise.
34
+
35
+ ## API
36
+
37
+ ### `createVmBuketti(options?)`
38
+
39
+ Returns a `VmBuketti` instance.
40
+
41
+ | Option | Type | Description |
42
+ |--------|------|-------------|
43
+ | `proxyUrl` | `string` | Prefix for CORS proxy requests, e.g. `'/proxy'`. Appends `?url=<encoded>`. |
44
+ | `fetchXml` | `(url: string) => Promise<string>` | Custom fetch function. Takes precedence over `proxyUrl`. |
45
+ | `onError` | `(error: Error, url: string) => void` | Called when a sub-link fetch fails. Defaults to silent (partial data returned). |
46
+
47
+ ### `service.getJuuri(): Juuri`
48
+
49
+ Returns the root node. Calling this does not trigger any network requests — fetches are deferred until properties are accessed.
50
+
51
+ ## Budget hierarchy
52
+
53
+ ```
54
+ Juuri
55
+ └── Vuosi[] (year)
56
+ └── Teos[] (publication: tae, ltae1–ltae7)
57
+ └── Kanta[] (version: hallituksenEsitys, eduskunnanKirjelma, …)
58
+ ├── Paaluokka[] → Menoluku[] → Menomomentti[] (expenses)
59
+ └── Osasto[] → Tuloluku[] → Tulomomentti[] (revenues)
60
+ ```
61
+
62
+ ## TypeScript types
63
+
64
+ All types mirror the XSD schema from budjetti.vm.fi. Lazy nodes (fetched on demand) use `Lazy*` prefixed types with `Promise<T[]>` children.
65
+
66
+ ```ts
67
+ import type { LazyVuosi, LazyKanta, Menoluku } from '@jvaarala/vm-buketti';
68
+ ```
69
+
70
+ Enumerated attributes (e.g. `teostyyppi`, `momenttityyppi`) include a `| (string & {})` fallback so that values the XSD hasn't yet listed — such as `ltae6`/`ltae7` in live data — are accepted without TypeScript errors while known values still benefit from autocomplete.
71
+
72
+ ## Regenerating types
73
+
74
+ If the upstream XSD changes, regenerate `src/schema.ts`:
75
+
76
+ ```sh
77
+ npm run gen-schema
78
+ ```
79
+
80
+ The generated file must not be edited by hand.
81
+
82
+ ## Requirements
83
+
84
+ - Node 18+ or any modern browser (uses `fetch` and `DOMParser`)
85
+ - No runtime dependencies
86
+
87
+ ## Data license
88
+
89
+ The budget data accessed through this library is published by the Finnish Ministry of Finance as part of the open government data programme. Budget proposals have been available in machine-readable XML format since 2014 (government proposals from 2014, Ministry of Finance drafts from 2016). Parliamentary letters are also published in machine-readable form.
90
+
91
+ The data is licensed under the **Creative Commons Attribution 4.0 International** license (CC BY 4.0):
92
+ http://creativecommons.org/licenses/by/4.0/
93
+
94
+ When using data retrieved via this library, you must credit the Ministry of Finance of Finland as the data source.
95
+
96
+ This library itself (the wrapper code) is MIT-licensed.
@@ -0,0 +1,42 @@
1
+ export type { Laskelmaraha, Muutos, Vertailuluvut, Budjettilaskelma, Tulomomentti, Tuloluku, Osasto, Menomomentti, Menoluku, Paaluokka, Kanta, Teos, Vuosi, LazyOsasto, LazyPaaluokka, LazyKanta, LazyTeos, LazyVuosi, Juuri, } from './schema.js';
2
+ import type { Juuri } from './schema.js';
3
+ export interface VmBukettiOptions {
4
+ /**
5
+ * URL of a CORS proxy endpoint that accepts `?url=<encoded>` query params.
6
+ *
7
+ * @example
8
+ * createVmBuketti({ proxyUrl: '/proxy' });
9
+ */
10
+ proxyUrl?: string;
11
+ /**
12
+ * Custom function for fetching XML content from a URL.
13
+ * Use this to add custom headers, authentication, etc.
14
+ * Takes precedence over `proxyUrl`.
15
+ *
16
+ * Defaults to `fetch(url)` using the global `fetch`.
17
+ */
18
+ fetchXml?: (url: string) => Promise<string>;
19
+ /**
20
+ * Called when a sub-link fetch fails during lazy loading.
21
+ * If not provided, failures are silently ignored (partial data is returned).
22
+ */
23
+ onError?: (error: Error, url: string) => void;
24
+ }
25
+ export interface VmBuketti {
26
+ getJuuri(): Juuri;
27
+ }
28
+ /**
29
+ * Creates a VmBuketti instance for accessing the Finnish state budget API.
30
+ *
31
+ * @example
32
+ * // Node.js (fetch available globally in Node 18+):
33
+ * const service = createVmBuketti();
34
+ *
35
+ * @example
36
+ * // Browser with CORS proxy:
37
+ * const service = createVmBuketti({
38
+ * fetchXml: url => fetch('/proxy?url=' + encodeURIComponent(url)).then(r => r.text())
39
+ * });
40
+ */
41
+ export declare function createVmBuketti(options?: VmBukettiOptions): VmBuketti;
42
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,gBAAgB,EACrD,YAAY,EAAE,QAAQ,EAAE,MAAM,EAC9B,YAAY,EAAE,QAAQ,EAAE,SAAS,EACjC,KAAK,EAAE,IAAI,EAAE,KAAK,EAClB,UAAU,EAAE,aAAa,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EACzD,KAAK,GACN,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAE5C;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC/C;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,IAAI,KAAK,CAAC;CACnB;AA2ID;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAuBrE"}
package/dist/index.js ADDED
@@ -0,0 +1,152 @@
1
+ const ROOT_URL = 'https://budjetti.vm.fi/opendata/opendata-xml.jsp';
2
+ const XLINK_NS = 'http://www.w3.org/1999/xlink';
3
+ // ── XML helpers ──────────────────────────────────────────────────────────────
4
+ function parseXML(text) {
5
+ const doc = new DOMParser().parseFromString(text, 'application/xml');
6
+ const err = doc.querySelector('parsererror');
7
+ if (err)
8
+ throw new Error('XML parse error: ' + err.textContent?.slice(0, 120));
9
+ return doc;
10
+ }
11
+ function getHref(el) {
12
+ return el.getAttributeNS(XLINK_NS, 'href') || el.getAttribute('xlink:href') || null;
13
+ }
14
+ function toCamel(s) {
15
+ return s.replace(/[-:]([a-z])/g, (_, c) => c.toUpperCase());
16
+ }
17
+ // ── Generic lazy tree builder ────────────────────────────────────────────────
18
+ //
19
+ // The traversal algorithm is the same for every node in the tree:
20
+ // - If the element has xlink:href it must be fetched before its children are
21
+ // readable. A Proxy intercepts child-property accesses and fires the fetch
22
+ // on demand, caching the resulting Promise.
23
+ // - Otherwise, children are already in the DOM and can be wrapped eagerly;
24
+ // but if any child element carries an xlink:href the child array is still
25
+ // wrapped in a Promise so callers use a consistent await pattern.
26
+ //
27
+ // The Juuri root is treated the same way via a document-level Proxy.
28
+ function readAttrs(el) {
29
+ const out = {};
30
+ for (const attr of el.attributes) {
31
+ if (attr.namespaceURI)
32
+ continue; // skip xlink:href, xmlns:* and any other namespace attrs
33
+ const key = toCamel(attr.name);
34
+ out[key] = /^-?\d+(\.\d+)?$/.test(attr.value) ? Number(attr.value) : attr.value;
35
+ }
36
+ return out;
37
+ }
38
+ function buildNode(el, rootDoc, resolveEl) {
39
+ const attrs = readAttrs(el);
40
+ if (getHref(el)) {
41
+ // xlink element: children unknown until fetched — use a Proxy
42
+ let resolved = null;
43
+ const ensure = () => {
44
+ if (!resolved)
45
+ resolved = resolveEl(el, rootDoc);
46
+ return resolved;
47
+ };
48
+ const childCache = new Map();
49
+ return new Proxy(attrs, {
50
+ get(target, prop) {
51
+ if (typeof prop !== 'string')
52
+ return undefined;
53
+ if (prop === 'then')
54
+ return undefined;
55
+ if (Object.prototype.hasOwnProperty.call(target, prop))
56
+ return target[prop];
57
+ if (!childCache.has(prop)) {
58
+ childCache.set(prop, ensure().then(() => {
59
+ const children = [...el.children].filter(c => c.tagName === prop);
60
+ return children.map(c => buildNode(c, rootDoc, resolveEl));
61
+ }));
62
+ }
63
+ return childCache.get(prop);
64
+ },
65
+ });
66
+ }
67
+ // Non-xlink element: children are in the DOM now
68
+ const node = { ...attrs };
69
+ const groups = new Map();
70
+ for (const child of el.children) {
71
+ if (!groups.has(child.tagName))
72
+ groups.set(child.tagName, []);
73
+ groups.get(child.tagName).push(child);
74
+ }
75
+ for (const [tag, children] of groups) {
76
+ node[tag] = children.map(c => buildNode(c, rootDoc, resolveEl));
77
+ }
78
+ return node;
79
+ }
80
+ function buildJuuri(fetchRoot, resolveEl) {
81
+ const childCache = new Map();
82
+ let docP = null;
83
+ const ensure = () => {
84
+ if (!docP)
85
+ docP = fetchRoot();
86
+ return docP;
87
+ };
88
+ return new Proxy({}, {
89
+ get(_, prop) {
90
+ if (typeof prop !== 'string' || prop === 'then')
91
+ return undefined;
92
+ if (!childCache.has(prop))
93
+ childCache.set(prop, ensure().then(doc => [...doc.documentElement.children]
94
+ .filter(c => c.tagName === prop)
95
+ .map(el => buildNode(el, doc, resolveEl))));
96
+ return childCache.get(prop);
97
+ },
98
+ });
99
+ }
100
+ // ── Fetch infrastructure ─────────────────────────────────────────────────────
101
+ function makeResolveEl(fetchXml, onError) {
102
+ return async function resolveEl(el, rootDoc) {
103
+ const h = getHref(el);
104
+ if (!h)
105
+ return;
106
+ const url = new URL(h, ROOT_URL).href;
107
+ try {
108
+ const text = await fetchXml(url);
109
+ const doc = parseXML(text);
110
+ while (el.lastChild)
111
+ el.removeChild(el.lastChild);
112
+ [...doc.documentElement.children].forEach(c => el.appendChild(rootDoc.importNode(c, true)));
113
+ el.removeAttributeNS(XLINK_NS, 'href');
114
+ el.removeAttribute('xlink:href');
115
+ }
116
+ catch (e) {
117
+ onError?.(e, url);
118
+ }
119
+ };
120
+ }
121
+ // ── Public factory ───────────────────────────────────────────────────────────
122
+ /**
123
+ * Creates a VmBuketti instance for accessing the Finnish state budget API.
124
+ *
125
+ * @example
126
+ * // Node.js (fetch available globally in Node 18+):
127
+ * const service = createVmBuketti();
128
+ *
129
+ * @example
130
+ * // Browser with CORS proxy:
131
+ * const service = createVmBuketti({
132
+ * fetchXml: url => fetch('/proxy?url=' + encodeURIComponent(url)).then(r => r.text())
133
+ * });
134
+ */
135
+ export function createVmBuketti(options) {
136
+ const doFetch = async (url) => {
137
+ const r = await fetch(url);
138
+ if (!r.ok)
139
+ throw new Error(`Server returned ${r.status} for ${url}`);
140
+ return r.text();
141
+ };
142
+ const fetchXml = options?.fetchXml ?? (options?.proxyUrl
143
+ ? (url) => doFetch(options.proxyUrl + '?url=' + encodeURIComponent(url))
144
+ : doFetch);
145
+ const resolveEl = makeResolveEl(fetchXml, options?.onError);
146
+ return {
147
+ getJuuri() {
148
+ return buildJuuri(() => fetchXml(ROOT_URL).then(parseXML), resolveEl);
149
+ },
150
+ };
151
+ }
152
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,98 @@
1
+ export interface Laskelmaraha {
2
+ tyyppi: 'toteutuma' | 'aiemmin-budjetoitu' | 'aiemmin-budjetoitu-ltae' | 'aiemmin-budjetoitu-ltae1' | 'aiemmin-budjetoitu-ltae2' | 'aiemmin-budjetoitu-ltae3' | 'aiemmin-budjetoitu-ltae4' | 'aiemmin-budjetoitu-ltae5' | 'muutosraha' | 'maararaha' | (string & {});
3
+ vuosi: string | null;
4
+ arvo: number | null;
5
+ }
6
+ export interface Muutos {
7
+ kuvaus: string | null;
8
+ Laskelmaraha: Laskelmaraha[];
9
+ }
10
+ export interface Vertailuluvut {
11
+ Laskelmaraha: Laskelmaraha[];
12
+ }
13
+ export interface Budjettilaskelma {
14
+ Laskelmaraha: Laskelmaraha[];
15
+ Muutos: Muutos[];
16
+ Vertailuluvut: Vertailuluvut[];
17
+ }
18
+ export interface Tulomomentti {
19
+ nimi: string | null;
20
+ numero: string | null;
21
+ momenttityyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'siirretty' | 'uusi' | (string & {}) | null;
22
+ infoOsa: string | null;
23
+ Budjettilaskelma: Budjettilaskelma[];
24
+ }
25
+ export interface Tuloluku {
26
+ nimi: string | null;
27
+ numero: string | null;
28
+ lukutyyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'uusi' | (string & {}) | null;
29
+ infoOsa: string | null;
30
+ Tulomomentti: Tulomomentti[];
31
+ }
32
+ export interface Osasto {
33
+ numero: string | null;
34
+ nimi: string | null;
35
+ Tuloluku: Tuloluku[];
36
+ }
37
+ export interface Menomomentti {
38
+ maararahalaji: 'arviomaararaha' | 'kiinteamaararaha' | 'siirtomaararaha_2v' | 'siirtomaararaha_3v' | 'siirtomaararaha_5v' | (string & {}) | null;
39
+ nimi: string | null;
40
+ numero: string | null;
41
+ momenttityyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'siirretty' | 'uusi' | (string & {}) | null;
42
+ infoOsa: string | null;
43
+ Budjettilaskelma: Budjettilaskelma[];
44
+ }
45
+ export interface Menoluku {
46
+ nimi: string | null;
47
+ numero: string | null;
48
+ lukutyyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'uusi' | (string & {}) | null;
49
+ infoOsa: string | null;
50
+ Menomomentti: Menomomentti[];
51
+ }
52
+ export interface Paaluokka {
53
+ numero: string | null;
54
+ nimi: string | null;
55
+ Menoluku: Menoluku[];
56
+ }
57
+ export interface Kanta {
58
+ kanta: 'valtiovarainministerionKanta' | 'hallituksenEsitys' | 'eduskunnanKirjelma' | (string & {}) | null;
59
+ Osasto: Osasto[];
60
+ Paaluokka: Paaluokka[];
61
+ }
62
+ export interface Teos {
63
+ nimi: string | null;
64
+ teostyyppi: 'tae' | 'ltae1' | 'ltae2' | 'ltae3' | 'ltae4' | 'ltae5' | (string & {}) | null;
65
+ Kanta: Kanta[];
66
+ }
67
+ export interface Vuosi {
68
+ vuosi: number;
69
+ Teos: Teos[];
70
+ }
71
+ export interface LazyOsasto {
72
+ numero: string | null;
73
+ nimi: string | null;
74
+ readonly Tuloluku: Promise<Tuloluku[]>;
75
+ }
76
+ export interface LazyPaaluokka {
77
+ numero: string | null;
78
+ nimi: string | null;
79
+ readonly Menoluku: Promise<Menoluku[]>;
80
+ }
81
+ export interface LazyKanta {
82
+ kanta: 'valtiovarainministerionKanta' | 'hallituksenEsitys' | 'eduskunnanKirjelma' | (string & {}) | null;
83
+ readonly Osasto: Promise<LazyOsasto[]>;
84
+ readonly Paaluokka: Promise<LazyPaaluokka[]>;
85
+ }
86
+ export interface LazyTeos {
87
+ nimi: string | null;
88
+ teostyyppi: 'tae' | 'ltae1' | 'ltae2' | 'ltae3' | 'ltae4' | 'ltae5' | (string & {}) | null;
89
+ Kanta: LazyKanta[];
90
+ }
91
+ export interface LazyVuosi {
92
+ vuosi: number;
93
+ Teos: LazyTeos[];
94
+ }
95
+ export interface Juuri {
96
+ readonly Vuosi: Promise<LazyVuosi[]>;
97
+ }
98
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
package/dist/schema.js ADDED
@@ -0,0 +1,4 @@
1
+ // Auto-generated from https://budjetti.vm.fi/indox/opendata/kl_buketti.xsd
2
+ // Regenerate with: npm run gen-schema
3
+ export {};
4
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,sCAAsC"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@jvaarala/vm-buketti",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript client for the Finnish state budget open data API (budjetti.vm.fi)",
5
+ "author": "Jesse Väärälä",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/jvaarala/vm-buketti.git"
10
+ },
11
+ "homepage": "https://github.com/jvaarala/vm-buketti#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/jvaarala/vm-buketti/issues"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "import": "./dist/index.js",
24
+ "types": "./dist/index.d.ts"
25
+ }
26
+ },
27
+ "sideEffects": false,
28
+ "files": ["dist", "src"],
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "typecheck": "tsc --noEmit",
32
+ "gen-schema": "node scripts/gen-schema.mjs",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "keywords": [
36
+ "finland",
37
+ "budget",
38
+ "talousarvio",
39
+ "budjetti",
40
+ "vm.fi",
41
+ "opendata",
42
+ "budjetti.vm.fi",
43
+ "api",
44
+ "typescript",
45
+ "client"
46
+ ],
47
+ "devDependencies": {
48
+ "typescript": "^6.0.2"
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,214 @@
1
+ export type {
2
+ Laskelmaraha, Muutos, Vertailuluvut, Budjettilaskelma,
3
+ Tulomomentti, Tuloluku, Osasto,
4
+ Menomomentti, Menoluku, Paaluokka,
5
+ Kanta, Teos, Vuosi,
6
+ LazyOsasto, LazyPaaluokka, LazyKanta, LazyTeos, LazyVuosi,
7
+ Juuri,
8
+ } from './schema.js';
9
+
10
+ import type { Juuri } from './schema.js';
11
+
12
+ export interface VmBukettiOptions {
13
+ /**
14
+ * URL of a CORS proxy endpoint that accepts `?url=<encoded>` query params.
15
+ *
16
+ * @example
17
+ * createVmBuketti({ proxyUrl: '/proxy' });
18
+ */
19
+ proxyUrl?: string;
20
+
21
+ /**
22
+ * Custom function for fetching XML content from a URL.
23
+ * Use this to add custom headers, authentication, etc.
24
+ * Takes precedence over `proxyUrl`.
25
+ *
26
+ * Defaults to `fetch(url)` using the global `fetch`.
27
+ */
28
+ fetchXml?: (url: string) => Promise<string>;
29
+
30
+ /**
31
+ * Called when a sub-link fetch fails during lazy loading.
32
+ * If not provided, failures are silently ignored (partial data is returned).
33
+ */
34
+ onError?: (error: Error, url: string) => void;
35
+ }
36
+
37
+ export interface VmBuketti {
38
+ getJuuri(): Juuri;
39
+ }
40
+
41
+ const ROOT_URL = 'https://budjetti.vm.fi/opendata/opendata-xml.jsp';
42
+ const XLINK_NS = 'http://www.w3.org/1999/xlink';
43
+
44
+ type ResolveEl = (el: Element, rootDoc: Document) => Promise<void>;
45
+
46
+ // ── XML helpers ──────────────────────────────────────────────────────────────
47
+
48
+ function parseXML(text: string): Document {
49
+ const doc = new DOMParser().parseFromString(text, 'application/xml');
50
+ const err = doc.querySelector('parsererror');
51
+ if (err) throw new Error('XML parse error: ' + err.textContent?.slice(0, 120));
52
+ return doc;
53
+ }
54
+
55
+ function getHref(el: Element): string | null {
56
+ return el.getAttributeNS(XLINK_NS, 'href') || el.getAttribute('xlink:href') || null;
57
+ }
58
+
59
+ function toCamel(s: string): string {
60
+ return s.replace(/[-:]([a-z])/g, (_, c: string) => c.toUpperCase());
61
+ }
62
+
63
+ // ── Generic lazy tree builder ────────────────────────────────────────────────
64
+ //
65
+ // The traversal algorithm is the same for every node in the tree:
66
+ // - If the element has xlink:href it must be fetched before its children are
67
+ // readable. A Proxy intercepts child-property accesses and fires the fetch
68
+ // on demand, caching the resulting Promise.
69
+ // - Otherwise, children are already in the DOM and can be wrapped eagerly;
70
+ // but if any child element carries an xlink:href the child array is still
71
+ // wrapped in a Promise so callers use a consistent await pattern.
72
+ //
73
+ // The Juuri root is treated the same way via a document-level Proxy.
74
+
75
+ function readAttrs(el: Element): Record<string, string | number> {
76
+ const out: Record<string, string | number> = {};
77
+ for (const attr of el.attributes) {
78
+ if (attr.namespaceURI) continue; // skip xlink:href, xmlns:* and any other namespace attrs
79
+ const key = toCamel(attr.name);
80
+ out[key] = /^-?\d+(\.\d+)?$/.test(attr.value) ? Number(attr.value) : attr.value;
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function buildNode(el: Element, rootDoc: Document, resolveEl: ResolveEl): unknown {
86
+ const attrs = readAttrs(el);
87
+
88
+ if (getHref(el)) {
89
+ // xlink element: children unknown until fetched — use a Proxy
90
+ let resolved: Promise<void> | null = null;
91
+ const ensure = (): Promise<void> => {
92
+ if (!resolved) resolved = resolveEl(el, rootDoc);
93
+ return resolved;
94
+ };
95
+ const childCache = new Map<string, Promise<unknown[]>>();
96
+
97
+ return new Proxy(attrs as Record<string, unknown>, {
98
+ get(target, prop) {
99
+ if (typeof prop !== 'string') return undefined;
100
+ if (prop === 'then') return undefined;
101
+ if (Object.prototype.hasOwnProperty.call(target, prop)) return target[prop];
102
+ if (!childCache.has(prop)) {
103
+ childCache.set(prop, ensure().then(() => {
104
+ const children = [...el.children].filter(c => c.tagName === prop);
105
+ return children.map(c => buildNode(c, rootDoc, resolveEl));
106
+ }));
107
+ }
108
+ return childCache.get(prop);
109
+ },
110
+ });
111
+ }
112
+
113
+ // Non-xlink element: children are in the DOM now
114
+ const node: Record<string, unknown> = { ...attrs };
115
+
116
+ const groups = new Map<string, Element[]>();
117
+ for (const child of el.children) {
118
+ if (!groups.has(child.tagName)) groups.set(child.tagName, []);
119
+ groups.get(child.tagName)!.push(child);
120
+ }
121
+
122
+ for (const [tag, children] of groups) {
123
+ node[tag] = children.map(c => buildNode(c, rootDoc, resolveEl));
124
+ }
125
+
126
+ return node;
127
+ }
128
+
129
+ function buildJuuri(fetchRoot: () => Promise<Document>, resolveEl: ResolveEl): Juuri {
130
+ const childCache = new Map<string, Promise<unknown[]>>();
131
+ let docP: Promise<Document> | null = null;
132
+ const ensure = (): Promise<Document> => {
133
+ if (!docP) docP = fetchRoot();
134
+ return docP;
135
+ };
136
+
137
+ return new Proxy({} as unknown as Juuri, {
138
+ get(_, prop) {
139
+ if (typeof prop !== 'string' || prop === 'then') return undefined;
140
+ if (!childCache.has(prop))
141
+ childCache.set(prop, ensure().then(doc =>
142
+ [...doc.documentElement.children]
143
+ .filter(c => c.tagName === prop)
144
+ .map(el => buildNode(el, doc, resolveEl))
145
+ ));
146
+ return childCache.get(prop);
147
+ },
148
+ });
149
+ }
150
+
151
+ // ── Fetch infrastructure ─────────────────────────────────────────────────────
152
+
153
+ function makeResolveEl(
154
+ fetchXml: (url: string) => Promise<string>,
155
+ onError?: (error: Error, url: string) => void,
156
+ ): ResolveEl {
157
+ return async function resolveEl(el: Element, rootDoc: Document): Promise<void> {
158
+ const h = getHref(el);
159
+ if (!h) return;
160
+ const url = new URL(h, ROOT_URL).href;
161
+ try {
162
+ const text = await fetchXml(url);
163
+ const doc = parseXML(text);
164
+ while (el.lastChild) el.removeChild(el.lastChild);
165
+ [...doc.documentElement.children].forEach(c =>
166
+ el.appendChild(rootDoc.importNode(c, true))
167
+ );
168
+ el.removeAttributeNS(XLINK_NS, 'href');
169
+ el.removeAttribute('xlink:href');
170
+ } catch (e) {
171
+ onError?.(e as Error, url);
172
+ }
173
+ };
174
+ }
175
+
176
+ // ── Public factory ───────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Creates a VmBuketti instance for accessing the Finnish state budget API.
180
+ *
181
+ * @example
182
+ * // Node.js (fetch available globally in Node 18+):
183
+ * const service = createVmBuketti();
184
+ *
185
+ * @example
186
+ * // Browser with CORS proxy:
187
+ * const service = createVmBuketti({
188
+ * fetchXml: url => fetch('/proxy?url=' + encodeURIComponent(url)).then(r => r.text())
189
+ * });
190
+ */
191
+ export function createVmBuketti(options?: VmBukettiOptions): VmBuketti {
192
+ const doFetch = async (url: string) => {
193
+ const r = await fetch(url);
194
+ if (!r.ok) throw new Error(`Server returned ${r.status} for ${url}`);
195
+ return r.text();
196
+ };
197
+
198
+ const fetchXml = options?.fetchXml ?? (
199
+ options?.proxyUrl
200
+ ? (url: string) => doFetch(options.proxyUrl! + '?url=' + encodeURIComponent(url))
201
+ : doFetch
202
+ );
203
+
204
+ const resolveEl = makeResolveEl(fetchXml, options?.onError);
205
+
206
+ return {
207
+ getJuuri(): Juuri {
208
+ return buildJuuri(
209
+ () => fetchXml(ROOT_URL).then(parseXML),
210
+ resolveEl,
211
+ );
212
+ },
213
+ };
214
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,120 @@
1
+ // Auto-generated from https://budjetti.vm.fi/indox/opendata/kl_buketti.xsd
2
+ // Regenerate with: npm run gen-schema
3
+
4
+ export interface Laskelmaraha {
5
+ tyyppi: 'toteutuma' | 'aiemmin-budjetoitu' | 'aiemmin-budjetoitu-ltae' | 'aiemmin-budjetoitu-ltae1' | 'aiemmin-budjetoitu-ltae2' | 'aiemmin-budjetoitu-ltae3' | 'aiemmin-budjetoitu-ltae4' | 'aiemmin-budjetoitu-ltae5' | 'muutosraha' | 'maararaha' | (string & {});
6
+ vuosi: string | null;
7
+ arvo: number | null;
8
+ }
9
+
10
+ export interface Muutos {
11
+ kuvaus: string | null;
12
+ Laskelmaraha: Laskelmaraha[];
13
+ }
14
+
15
+ export interface Vertailuluvut {
16
+ Laskelmaraha: Laskelmaraha[];
17
+ }
18
+
19
+ export interface Budjettilaskelma {
20
+ Laskelmaraha: Laskelmaraha[];
21
+ Muutos: Muutos[];
22
+ Vertailuluvut: Vertailuluvut[];
23
+ }
24
+
25
+ export interface Tulomomentti {
26
+ nimi: string | null;
27
+ numero: string | null;
28
+ momenttityyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'siirretty' | 'uusi' | (string & {}) | null;
29
+ infoOsa: string | null;
30
+ Budjettilaskelma: Budjettilaskelma[];
31
+ }
32
+
33
+ export interface Tuloluku {
34
+ nimi: string | null;
35
+ numero: string | null;
36
+ lukutyyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'uusi' | (string & {}) | null;
37
+ infoOsa: string | null;
38
+ Tulomomentti: Tulomomentti[];
39
+ }
40
+
41
+ export interface Osasto {
42
+ numero: string | null;
43
+ nimi: string | null;
44
+ Tuloluku: Tuloluku[];
45
+ }
46
+
47
+ export interface Menomomentti {
48
+ maararahalaji: 'arviomaararaha' | 'kiinteamaararaha' | 'siirtomaararaha_2v' | 'siirtomaararaha_3v' | 'siirtomaararaha_5v' | (string & {}) | null;
49
+ nimi: string | null;
50
+ numero: string | null;
51
+ momenttityyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'siirretty' | 'uusi' | (string & {}) | null;
52
+ infoOsa: string | null;
53
+ Budjettilaskelma: Budjettilaskelma[];
54
+ }
55
+
56
+ export interface Menoluku {
57
+ nimi: string | null;
58
+ numero: string | null;
59
+ lukutyyppi: 'aktiivinen' | 'poistettava' | 'poistettu' | 'uusi' | (string & {}) | null;
60
+ infoOsa: string | null;
61
+ Menomomentti: Menomomentti[];
62
+ }
63
+
64
+ export interface Paaluokka {
65
+ numero: string | null;
66
+ nimi: string | null;
67
+ Menoluku: Menoluku[];
68
+ }
69
+
70
+ export interface Kanta {
71
+ kanta: 'valtiovarainministerionKanta' | 'hallituksenEsitys' | 'eduskunnanKirjelma' | (string & {}) | null;
72
+ Osasto: Osasto[];
73
+ Paaluokka: Paaluokka[];
74
+ }
75
+
76
+ export interface Teos {
77
+ nimi: string | null;
78
+ teostyyppi: 'tae' | 'ltae1' | 'ltae2' | 'ltae3' | 'ltae4' | 'ltae5' | (string & {}) | null;
79
+ Kanta: Kanta[];
80
+ }
81
+
82
+ export interface Vuosi {
83
+ vuosi: number;
84
+ Teos: Teos[];
85
+ }
86
+
87
+ // ── Lazy types (for elements with xlink:href and their ancestors) ────────────
88
+
89
+ export interface LazyOsasto {
90
+ numero: string | null;
91
+ nimi: string | null;
92
+ readonly Tuloluku: Promise<Tuloluku[]>;
93
+ }
94
+
95
+ export interface LazyPaaluokka {
96
+ numero: string | null;
97
+ nimi: string | null;
98
+ readonly Menoluku: Promise<Menoluku[]>;
99
+ }
100
+
101
+ export interface LazyKanta {
102
+ kanta: 'valtiovarainministerionKanta' | 'hallituksenEsitys' | 'eduskunnanKirjelma' | (string & {}) | null;
103
+ readonly Osasto: Promise<LazyOsasto[]>;
104
+ readonly Paaluokka: Promise<LazyPaaluokka[]>;
105
+ }
106
+
107
+ export interface LazyTeos {
108
+ nimi: string | null;
109
+ teostyyppi: 'tae' | 'ltae1' | 'ltae2' | 'ltae3' | 'ltae4' | 'ltae5' | (string & {}) | null;
110
+ Kanta: LazyKanta[];
111
+ }
112
+
113
+ export interface LazyVuosi {
114
+ vuosi: number;
115
+ Teos: LazyTeos[];
116
+ }
117
+
118
+ export interface Juuri {
119
+ readonly Vuosi: Promise<LazyVuosi[]>;
120
+ }