malawi-curriculum-api 1.0.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 ADDED
@@ -0,0 +1,424 @@
1
+ # Malawi Curriculum API - JavaScript SDK
2
+
3
+ A simple, typed client for accessing the Malawi Curriculum API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install malawi-curriculum-api
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```javascript
14
+ import { MalawiCurriculumClient } from 'malawi-curriculum-api';
15
+
16
+ const client = new MalawiCurriculumClient({
17
+ apiKey: 'YOUR_API_KEY'
18
+ });
19
+
20
+ const resources = await client.getResources({
21
+ level: 'MSCE',
22
+ subject: 'Mathematics',
23
+ type: 'past_paper'
24
+ });
25
+ ```
26
+
27
+ ## API Overview
28
+
29
+ The Malawi Curriculum API provides access to educational resources across various academic levels in Malawi. All endpoints require authentication via an API key.
30
+
31
+ ### Base URL
32
+
33
+ ```
34
+ https://malawi-curricular-api-production.up.railway.app/api/v1
35
+ ```
36
+
37
+ ### Authentication
38
+
39
+ Include your API key in the `Authorization` header:
40
+
41
+ ```
42
+ Authorization: Bearer YOUR_API_KEY
43
+ ```
44
+
45
+ ## API Reference
46
+
47
+ ### Get Levels
48
+
49
+ Retrieves all academic levels available in the Malawi curriculum.
50
+
51
+ ```javascript
52
+ const levels = await client.getLevels();
53
+ ```
54
+
55
+ **Request:**
56
+ - Method: `GET`
57
+ - Endpoint: `/levels`
58
+ - Headers: `Authorization: Bearer API_KEY`
59
+
60
+ **Response:**
61
+ ```json
62
+ {
63
+ "success": true,
64
+ "count": 3,
65
+ "data": [
66
+ { "id": 1, "name": "JCE" },
67
+ { "id": 2, "name": "MSCE" },
68
+ { "id": 3, "name": "Primary" }
69
+ ]
70
+ }
71
+ ```
72
+
73
+ ---
74
+
75
+ ### Get Subjects
76
+
77
+ Retrieves subjects, optionally filtered by academic level.
78
+
79
+ ```javascript
80
+ const subjects = await client.getSubjects('MSCE');
81
+ ```
82
+
83
+ **Request:**
84
+ - Method: `GET`
85
+ - Endpoint: `/subjects`
86
+ - Headers: `Authorization: Bearer API_KEY`
87
+ - Query Parameters:
88
+ - `level` (optional): Filter by level name (e.g., "MSCE", "JCE")
89
+
90
+ **Response:**
91
+ ```json
92
+ {
93
+ "success": true,
94
+ "count": 12,
95
+ "data": [
96
+ { "id": 1, "name": "Mathematics", "level": "MSCE" },
97
+ { "id": 2, "name": "Physics", "level": "MSCE" },
98
+ { "id": 3, "name": "Chemistry", "level": "MSCE" }
99
+ ]
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ### Get Resources
106
+
107
+ Retrieves curriculum resources with filtering options.
108
+
109
+ ```javascript
110
+ const resources = await client.getResources({
111
+ level: 'MSCE',
112
+ subject: 'Mathematics',
113
+ type: 'past_paper',
114
+ year: 2023,
115
+ limit: 10
116
+ });
117
+ ```
118
+
119
+ **Request:**
120
+ - Method: `GET`
121
+ - Endpoint: `/resources`
122
+ - Headers: `Authorization: Bearer API_KEY`
123
+ - Query Parameters:
124
+ - `level` (required): Academic level (e.g., "MSCE", "JCE")
125
+ - `subject` (required): Subject name (e.g., "Mathematics")
126
+ - `type` (optional): Resource type
127
+ - `past_paper`
128
+ - `marking_scheme`
129
+ - `textbook`
130
+ - `teacher_notes`
131
+ - `student_notes`
132
+ - `scheme_of_work`
133
+ - `year` (optional): Year of the resource (integer)
134
+ - `limit` (optional): Number of results (1-100, default: 50)
135
+ - `offset` (optional): Pagination offset (default: 0)
136
+
137
+ **Response:**
138
+ ```json
139
+ {
140
+ "success": true,
141
+ "count": 5,
142
+ "total_count": 47,
143
+ "data": [
144
+ {
145
+ "id": 101,
146
+ "title": "MSCE Mathematics Paper 1 2023",
147
+ "type": "past_paper",
148
+ "year": 2023,
149
+ "description": "Main examination paper",
150
+ "subject": "Mathematics",
151
+ "level": "MSCE"
152
+ }
153
+ ]
154
+ }
155
+ ```
156
+
157
+ ---
158
+
159
+ ### Download Resource (Secure Token Flow)
160
+
161
+ Downloads use a two-step token flow for security. Requires a paid subscription (Basic, Pro, or Enterprise).
162
+
163
+ #### Request a Download Token
164
+
165
+ ```javascript
166
+ const tokenData = await client.requestDownload(101);
167
+ console.log('Token:', tokenData.token);
168
+ console.log('Expires in:', tokenData.expires_in_seconds, 'seconds');
169
+ ```
170
+
171
+ **Request:**
172
+ - Method: `POST`
173
+ - Endpoint: `/downloads/request`
174
+ - Headers: `Authorization: Bearer API_KEY`
175
+ - Body: `{ "resourceId": 101 }`
176
+
177
+ **Response:**
178
+ ```json
179
+ {
180
+ "success": true,
181
+ "token": "a1b2c3d4e5f6...",
182
+ "expires_in_seconds": 900,
183
+ "download_url": "/api/v1/downloads/a1b2c3d4e5f6..."
184
+ }
185
+ ```
186
+
187
+ #### Redeem the Token
188
+
189
+ ```javascript
190
+ const download = await client.redeemDownload(tokenData.token);
191
+ console.log('File URL:', download.download_url);
192
+ // URL is valid for 5 minutes, max 2 attempts per token
193
+ ```
194
+
195
+ **Request:**
196
+ - Method: `GET`
197
+ - Endpoint: `/downloads/{token}`
198
+ - No API key required (token is the authentication)
199
+
200
+ **Response:**
201
+ ```json
202
+ {
203
+ "success": true,
204
+ "download_url": "https://storage.googleapis.com/...",
205
+ "expires_in_seconds": 300,
206
+ "attempts_remaining": 1
207
+ }
208
+ ```
209
+
210
+ **Error Responses:**
211
+ ```json
212
+ // Free Tier
213
+ { "error": "Free tier cannot download files.", "code": "PLAN_INSUFFICIENT" }
214
+
215
+ // Limit Exceeded
216
+ { "error": "Daily download limit exceeded (100/day).", "code": "DOWNLOAD_LIMIT_EXCEEDED" }
217
+
218
+ // Token Expired
219
+ { "error": "Download token has expired.", "code": "TOKEN_EXPIRED" }
220
+
221
+ // Max Attempts
222
+ { "error": "Maximum download attempts reached (2).", "code": "TOKEN_MAX_ATTEMPTS" }
223
+ ```
224
+
225
+ > **Security:** Tokens expire after 15 minutes, allow max 2 download attempts, and are stored as SHA-256 hashes in the database. The signed download URL is only valid for 5 minutes.
226
+
227
+ ---
228
+
229
+ ### Resource Pricing
230
+
231
+ Set custom prices on resources or mark them as free. Each developer's pricing is independent, identified by their API key.
232
+
233
+ #### Set a Price
234
+
235
+ ```javascript
236
+ // Mark a resource as paid (500 MWK)
237
+ const pricing = await client.setPrice({
238
+ resourceId: 101,
239
+ price: 500,
240
+ isFree: false
241
+ });
242
+ console.log(pricing);
243
+ // { resource_id: 101, price_mwk: 500, is_free: false }
244
+
245
+ // Mark a resource as free
246
+ await client.setPrice({ resourceId: 101, isFree: true });
247
+ ```
248
+
249
+ **Request:**
250
+ - Method: `POST`
251
+ - Endpoint: `/pricing/set`
252
+ - Headers: `Authorization: Bearer API_KEY`
253
+ - Body:
254
+ - `resourceId` (required): Resource ID
255
+ - `price` (optional): Price in MWK (ignored if `isFree` is true)
256
+ - `isFree` (optional): Set to `true` for free access
257
+
258
+ **Response:**
259
+ ```json
260
+ {
261
+ "success": true,
262
+ "data": {
263
+ "resource_id": 101,
264
+ "price_mwk": 500,
265
+ "is_free": false
266
+ }
267
+ }
268
+ ```
269
+
270
+ #### Get a Price
271
+
272
+ ```javascript
273
+ const pricing = await client.getPrice(101);
274
+ console.log(pricing.is_free); // true or false
275
+ console.log(pricing.price_mwk); // price in MWK
276
+ ```
277
+
278
+ **Request:**
279
+ - Method: `GET`
280
+ - Endpoint: `/pricing/{resourceId}`
281
+ - Headers: `Authorization: Bearer API_KEY`
282
+
283
+ **Response:**
284
+ ```json
285
+ {
286
+ "success": true,
287
+ "data": {
288
+ "resource_id": 101,
289
+ "price_mwk": 0,
290
+ "is_free": true
291
+ }
292
+ }
293
+ ```
294
+
295
+ If no price has been set, the resource defaults to free.
296
+
297
+ **Download Enforcement:**
298
+
299
+ When a resource has been priced (not free), download requests return `402 Payment Required`:
300
+
301
+ ```json
302
+ {
303
+ "error": "This resource requires purchase before downloading.",
304
+ "code": "PAYMENT_REQUIRED",
305
+ "price_mwk": 500
306
+ }
307
+ ```
308
+
309
+ ---
310
+
311
+ ### Search Resources
312
+
313
+ Search across all curriculum resources. Results and filter capabilities depend on your plan tier.
314
+
315
+ ```javascript
316
+ const results = await client.search({
317
+ q: 'biology past paper',
318
+ level: 'MSCE', // Basic+ plans
319
+ type: 'past_paper', // Pro+ plans
320
+ year: 2024 // Pro+ plans
321
+ });
322
+ ```
323
+
324
+ **Request:**
325
+ - Method: `GET`
326
+ - Endpoint: `/search`
327
+ - Headers: `Authorization: Bearer API_KEY`
328
+ - Query Parameters:
329
+ - `q` (required): Search query (min 2 characters)
330
+ - `level` (optional): Filter by level -- Basic+ plans only
331
+ - `subject` (optional): Filter by subject -- Basic+ plans only
332
+ - `type` (optional): Filter by resource type -- Pro+ plans only
333
+ - `year` (optional): Filter by year -- Pro+ plans only
334
+ - `limit` (optional): Max results (capped by plan tier)
335
+ - `offset` (optional): Pagination offset
336
+ - `sort` (optional): Sort order -- Enterprise only (`relevance`, `newest`, `oldest`, `title`)
337
+
338
+ **Response:**
339
+ ```json
340
+ {
341
+ "success": true,
342
+ "count": 5,
343
+ "total_count": 23,
344
+ "tier": "basic",
345
+ "tier_info": "Title & description search with basic filters.",
346
+ "data": [
347
+ {
348
+ "id": 101,
349
+ "title": "MSCE Biology Paper 1 2024",
350
+ "type": "past_paper",
351
+ "year": 2024,
352
+ "subject": "Biology",
353
+ "level": "MSCE",
354
+ "relevance": 0.8721
355
+ }
356
+ ]
357
+ }
358
+ ```
359
+
360
+ **Search Tier Limits:**
361
+
362
+ | Feature | Free | Basic | Pro | Enterprise |
363
+ |---|---|---|---|---|
364
+ | Search fields | Title only | Title + Description | Title + Description | Title + Description |
365
+ | Max results | 10 | 50 | 100 | 500 |
366
+ | Filters | None | Level, Subject | All | All + Sorting |
367
+ ```
368
+
369
+ ## Error Handling
370
+
371
+ All methods may throw errors for various reasons:
372
+
373
+ ```javascript
374
+ try {
375
+ const resources = await client.getResources({ level: 'MSCE', subject: 'Math' });
376
+ } catch (error) {
377
+ if (error.response?.status === 401) {
378
+ console.error('Invalid API key');
379
+ } else if (error.response?.status === 402) {
380
+ console.error('Payment required for this resource');
381
+ } else if (error.response?.status === 429) {
382
+ console.error('Rate limit exceeded');
383
+ } else {
384
+ console.error('Error:', error.message);
385
+ }
386
+ }
387
+ ```
388
+
389
+ ### Common HTTP Status Codes
390
+
391
+ - `200` - Success
392
+ - `401` - Unauthorized (invalid or missing API key)
393
+ - `402` - Payment Required (resource has a price set)
394
+ - `403` - Forbidden (subscription expired or insufficient permissions)
395
+ - `404` - Resource not found
396
+ - `429` - Rate limit exceeded
397
+
398
+ ## Rate Limits & Plans
399
+
400
+ Different subscription tiers provide varying levels of access. Visit the [developer portal](https://malawi-curricular-api-production.up.railway.app) for current pricing.
401
+
402
+ ### Free Plan
403
+ - 100 requests/day
404
+ - Metadata access only
405
+ - No file downloads
406
+
407
+ ### Basic Plan
408
+ - 1,000 requests/day
409
+ - 5 downloads/day
410
+ - Full API access
411
+
412
+ ### Pro Plan
413
+ - 10,000 requests/day
414
+ - 100 downloads/day
415
+ - Priority support
416
+
417
+ ### Enterprise Plan
418
+ - 100,000 requests/day
419
+ - 1,000 downloads/day
420
+ - 24/7 support
421
+
422
+ ## License
423
+
424
+ ISC
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "malawi-curriculum-api",
3
+ "version": "1.0.3",
4
+ "description": "Official JavaScript SDK for the Malawi Curriculum API",
5
+ "main": "src/MalawiCurriculumClient.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "node test-sdk.js"
9
+ },
10
+ "keywords": [
11
+ "malawi",
12
+ "curriculum",
13
+ "education",
14
+ "api",
15
+ "sdk"
16
+ ],
17
+ "author": "CodingCodeMW",
18
+ "license": "ISC",
19
+ "dependencies": {
20
+ "node-fetch": "^3.3.0"
21
+ }
22
+ }
@@ -0,0 +1,209 @@
1
+
2
+ /**
3
+ * @typedef {Object} ResourceFilter
4
+ * @property {string} [level] - Education level (e.g., 'MSCE', 'JCE', 'PRIMARY')
5
+ * @property {string} [subject] - Subject name (e.g., 'Mathematics')
6
+ * @property {string} [type] - Resource type (e.g., 'past_paper', 'textbook')
7
+ * @property {number} [year] - Year of the resource
8
+ * @property {number} [limit] - Max number of results (1-100)
9
+ * @property {number} [offset] - Pagination offset
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} Resource
14
+ * @property {number} id
15
+ * @property {string} title
16
+ * @property {string} type
17
+ * @property {string} subject
18
+ * @property {string} level
19
+ * @property {number|null} year
20
+ * @property {string|null} description
21
+ */
22
+
23
+ export class MalawiCurriculumClient {
24
+ /**
25
+ * @param {Object} config
26
+ * @param {string} config.apiKey - Your API Key
27
+ * @param {string} [config.baseUrl] - API Base URL (default: https://malawi-curricular-api-production.up.railway.app/api/v1)
28
+ */
29
+ constructor({ apiKey, baseUrl }) {
30
+ if (!apiKey) throw new Error('API Key is required');
31
+
32
+ this.apiKey = apiKey;
33
+ this.baseUrl = baseUrl || 'https://malawi-curricular-api-production.up.railway.app/api/v1';
34
+ }
35
+
36
+ /**
37
+ * Helper to make authenticated requests
38
+ * @private
39
+ */
40
+ async _request(endpoint, query = {}) {
41
+ // Filter out undefined query params
42
+ const params = new URLSearchParams();
43
+ Object.entries(query).forEach(([key, value]) => {
44
+ if (value !== undefined && value !== null) {
45
+ params.append(key, String(value));
46
+ }
47
+ });
48
+
49
+ const url = `${this.baseUrl}${endpoint}?${params.toString()}`;
50
+
51
+ try {
52
+ const response = await fetch(url, {
53
+ headers: {
54
+ 'Authorization': `Bearer ${this.apiKey}`,
55
+ 'Content-Type': 'application/json'
56
+ }
57
+ });
58
+
59
+ if (!response.ok) {
60
+ const errorText = await response.text();
61
+ throw new Error(`API Error ${response.status}: ${errorText}`);
62
+ }
63
+
64
+ const json = await response.json();
65
+ return json;
66
+ } catch (error) {
67
+ if (error.cause) console.error('Network Error Cause:', error.cause);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Helper to make authenticated POST requests
74
+ * @private
75
+ */
76
+ async _post(endpoint, body = {}) {
77
+ const url = `${this.baseUrl}${endpoint}`;
78
+
79
+ try {
80
+ const response = await fetch(url, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Authorization': `Bearer ${this.apiKey}`,
84
+ 'Content-Type': 'application/json'
85
+ },
86
+ body: JSON.stringify(body)
87
+ });
88
+
89
+ if (!response.ok) {
90
+ const errorText = await response.text();
91
+ throw new Error(`API Error ${response.status}: ${errorText}`);
92
+ }
93
+
94
+ return await response.json();
95
+ } catch (error) {
96
+ if (error.cause) console.error('Network Error Cause:', error.cause);
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Get filtered resources
103
+ * @param {ResourceFilter} filter
104
+ * @returns {Promise<Resource[]>}
105
+ */
106
+ async getResources(filter = {}) {
107
+ const response = await this._request('/resources', filter);
108
+ return response.data || [];
109
+ }
110
+
111
+ /**
112
+ * Get all subjects, optionally filtered by level
113
+ * @param {string} [level]
114
+ * @returns {Promise<Object[]>}
115
+ */
116
+ async getSubjects(level) {
117
+ const response = await this._request('/subjects', { level });
118
+ return response.data || [];
119
+ }
120
+
121
+ /**
122
+ * Get all education levels
123
+ * @returns {Promise<Object[]>}
124
+ */
125
+ async getLevels() {
126
+ const response = await this._request('/levels');
127
+ return response.data || [];
128
+ }
129
+
130
+ /**
131
+ * Search resources with plan-tiered filtering
132
+ * @param {Object} options
133
+ * @param {string} options.q - Search query (required, min 2 chars)
134
+ * @param {string} [options.level] - Filter by level (Basic+ plans)
135
+ * @param {string} [options.subject] - Filter by subject (Basic+ plans)
136
+ * @param {string} [options.type] - Filter by type (Pro+ plans)
137
+ * @param {number} [options.year] - Filter by year (Pro+ plans)
138
+ * @param {number} [options.limit] - Max results (capped by plan)
139
+ * @param {number} [options.offset] - Pagination offset
140
+ * @param {string} [options.sort] - Sort order (Enterprise only: 'relevance', 'newest', 'oldest', 'title')
141
+ * @returns {Promise<Object>} Search results with tier info
142
+ */
143
+ async search(options = {}) {
144
+ return await this._request('/search', options);
145
+ }
146
+
147
+ /**
148
+ * Request a secure download token (paid plans only)
149
+ * @param {number} resourceId - ID of the resource to download
150
+ * @returns {Promise<{token: string, expires_in_seconds: number, download_url: string}>}
151
+ */
152
+ async requestDownload(resourceId) {
153
+ return await this._post('/downloads/request', { resourceId });
154
+ }
155
+
156
+ /**
157
+ * Redeem a download token to get the file URL
158
+ * @param {string} token - Token from requestDownload()
159
+ * @returns {Promise<{download_url: string, expires_in_seconds: number, attempts_remaining: number}>}
160
+ */
161
+ async redeemDownload(token) {
162
+ return await this._request(`/downloads/${token}`);
163
+ }
164
+
165
+ /**
166
+ * Convenience: Request token and redeem in one call
167
+ * @param {number} resourceId
168
+ * @returns {Promise<string>} Signed download URL
169
+ */
170
+ async download(resourceId) {
171
+ const { token } = await this.requestDownload(resourceId);
172
+ const { download_url } = await this.redeemDownload(token);
173
+ return download_url;
174
+ }
175
+
176
+ /**
177
+ * @deprecated Use requestDownload() + redeemDownload() or download() instead
178
+ * @param {number} id
179
+ * @returns {Promise<string>} Signed download URL
180
+ */
181
+ async downloadResource(id) {
182
+ console.warn('[DEPRECATED] downloadResource() is deprecated. Use download() for the secure token flow.');
183
+ const response = await this._request(`/resources/${id}/download`);
184
+ return response.download_url;
185
+ }
186
+
187
+ /**
188
+ * Set or update pricing for a resource
189
+ * @param {Object} options
190
+ * @param {number} options.resourceId - ID of the resource to price
191
+ * @param {number} [options.price] - Price in MWK (ignored if isFree is true)
192
+ * @param {boolean} [options.isFree] - Set to true to make the resource free
193
+ * @returns {Promise<{resource_id: number, price_mwk: number, is_free: boolean}>}
194
+ */
195
+ async setPrice({ resourceId, price, isFree }) {
196
+ const response = await this._post('/pricing/set', { resourceId, price, isFree });
197
+ return response.data;
198
+ }
199
+
200
+ /**
201
+ * Get the price set for a specific resource
202
+ * @param {number} resourceId - ID of the resource
203
+ * @returns {Promise<{resource_id: number, price_mwk: number, is_free: boolean}>}
204
+ */
205
+ async getPrice(resourceId) {
206
+ const response = await this._request(`/pricing/${resourceId}`);
207
+ return response.data;
208
+ }
209
+ }
package/test-sdk.js ADDED
@@ -0,0 +1,42 @@
1
+
2
+ import { MalawiCurriculumClient } from './src/MalawiCurriculumClient.js';
3
+
4
+ // Use the known test key or replace with yours
5
+ const API_KEY = 'YOUR_API_KEY'; // Replace with your actual key
6
+ const BASE_URL = 'https://malawi-curricular-api-production.up.railway.app/api/v1';
7
+
8
+ async function testSDK() {
9
+ console.log('Testing Malawi Curriculum SDK...');
10
+
11
+ const client = new MalawiCurriculumClient({
12
+ apiKey: API_KEY,
13
+ baseUrl: BASE_URL
14
+ });
15
+
16
+ try {
17
+ console.log('\nFetching Subjects...');
18
+ const subjects = await client.getSubjects();
19
+ console.log(`Success! Found ${subjects.length} subjects.`);
20
+ if (subjects.length > 0) console.log(' Sample:', subjects[0]);
21
+
22
+ console.log('\nFetching Levels...');
23
+ const levels = await client.getLevels();
24
+ console.log(`Success! Found ${levels.length} levels.`);
25
+ if (levels.length > 0) console.log(' Sample:', levels[0]);
26
+
27
+ console.log('\nFetching Resources (MSCE Mathematics)...');
28
+ const resources = await client.getResources({
29
+ level: 'MSCE',
30
+ subject: 'Mathematics',
31
+ limit: 5
32
+ });
33
+ console.log(`Success! Found ${resources.length} resources.`);
34
+ if (resources.length > 0) console.log(' Sample:', resources[0]);
35
+
36
+ } catch (error) {
37
+ console.error('SDK Test Failed:', error.message);
38
+ if (error.cause) console.error(' Cause:', error.cause);
39
+ }
40
+ }
41
+
42
+ testSDK();