mumin-api-monorepo 1.0.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.
@@ -0,0 +1,8 @@
1
+ # Changesets
2
+
3
+ Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
+ with multi-package repos, or single-package repos to help you version and publish your code. You can
5
+ find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6
+
7
+ We have a quick list of common questions to get you started engaging with this project in
8
+ [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "fixed": [],
6
+ "linked": [],
7
+ "access": "restricted",
8
+ "baseBranch": "main",
9
+ "updateInternalDependencies": "patch",
10
+ "ignore": []
11
+ }
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # 🕌 Mumin API SDK
2
+
3
+ The official, production-ready TypeScript SDK for the **Mumin Hadith API**.
4
+ Build Islamic applications with confidence using our fully typed, cached, and robust client libraries.
5
+
6
+ ![License](https://img.shields.io/badge/license-MIT-blue.svg)
7
+ ![TypeScript](https://img.shields.io/badge/language-TypeScript-3178C6.svg)
8
+ ![Version](https://img.shields.io/badge/version-1.0.0-green)
9
+
10
+ ---
11
+
12
+ ## ✨ Features
13
+
14
+ - **🛡️ Type Safety**: Full TypeScript support. No more guessing properties — getting `hadith.translation.text` is fully typed.
15
+ - **🧠 Intelligent Caching**: Built-in `MemoryCache` stores responses to reduce API calls and latency.
16
+ - **🔁 Auto-Retry**: Network glitches? The SDK automatically retries failed requests with exponential backoff.
17
+ - **📦 Monorepo Architecture**: Modular packages for **Core** (Node.js/JS), **React** hooks, and **Vue** composables.
18
+ - **⚡ Lightweight**: Zero-dependency core (uses native `fetch`).
19
+
20
+ ---
21
+
22
+ ## 📦 Installation
23
+
24
+ We provide separate packages depending on your framework:
25
+
26
+ ### Core (Node.js / Vanilla JS / Next.js API)
27
+
28
+ ```bash
29
+ npm install @mumin/core
30
+ ```
31
+
32
+ ### React
33
+
34
+ ```bash
35
+ npm install @mumin/react @mumin/core
36
+ ```
37
+
38
+ ### Vue 3
39
+
40
+ ```bash
41
+ npm install @mumin/vue @mumin/core
42
+ ```
43
+
44
+ ---
45
+
46
+ ## 🚀 Quick Start
47
+
48
+ ### 1. Vanilla JS / Node.js
49
+
50
+ Perfect for server-side code or simple scripts.
51
+
52
+ ```typescript
53
+ import { MuminClient } from "@mumin/core";
54
+
55
+ const client = new MuminClient("YOUR_API_KEY");
56
+
57
+ async function main() {
58
+ // Get a random hadith from Sahih al-Bukhari
59
+ const random = await client.hadiths.random({ collection: "sahih-bukhari" });
60
+
61
+ console.log("Hadith #", random.hadithNumber);
62
+ console.log("Text:", random.translation?.text || random.arabicText);
63
+
64
+ // Search for hadiths
65
+ const results = await client.search.query("prayer", { limit: 5 });
66
+ console.log("Found:", results.data.length);
67
+ }
68
+
69
+ main();
70
+ ```
71
+
72
+ ### 2. React
73
+
74
+ Use our dedicated hooks for easy data fetching.
75
+
76
+ **App.tsx**
77
+
78
+ ```tsx
79
+ import { MuminProvider } from "@mumin/react";
80
+
81
+ function App() {
82
+ return (
83
+ <MuminProvider apiKey="YOUR_API_KEY">
84
+ <HadithCard />
85
+ </MuminProvider>
86
+ );
87
+ }
88
+ ```
89
+
90
+ **HadithCard.tsx**
91
+
92
+ ```tsx
93
+ import { useHadith } from "@mumin/react";
94
+
95
+ export function HadithCard() {
96
+ // Fetch Hadith #1 from Sahih Muslim
97
+ const { data, loading, error } = useHadith(1, { lang: "en" });
98
+
99
+ if (loading) return <div>Loading...</div>;
100
+ if (error) return <div>Error: {error.message}</div>;
101
+
102
+ return (
103
+ <div className="card">
104
+ <h3>
105
+ {data?.collection?.name} - #{data?.hadithNumber}
106
+ </h3>
107
+ <p>{data?.translation?.text}</p>
108
+ </div>
109
+ );
110
+ }
111
+ ```
112
+
113
+ ### 3. Vue 3
114
+
115
+ Reactive composables for your Vue apps.
116
+
117
+ **main.ts**
118
+
119
+ ```typescript
120
+ import { createApp } from "vue";
121
+ import { MuminPlugin } from "@mumin/vue";
122
+ import App from "./App.vue";
123
+
124
+ const app = createApp(App);
125
+ app.use(MuminPlugin, { apiKey: "YOUR_API_KEY" });
126
+ app.mount("#app");
127
+ ```
128
+
129
+ **HadithView.vue**
130
+
131
+ ```vue
132
+ <script setup>
133
+ import { useHadith } from "@mumin/vue";
134
+
135
+ const { data, loading } = useHadith(1);
136
+ </script>
137
+
138
+ <template>
139
+ <div v-if="loading">Loading...</div>
140
+ <div v-else>
141
+ {{ data?.translation?.text }}
142
+ </div>
143
+ </template>
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 🛠️ Configuration
149
+
150
+ The `MuminClient` is highly configurable:
151
+
152
+ ```typescript
153
+ const client = new MuminClient("API_KEY", {
154
+ baseURL: "https://api.hadith.mumin.ink/v1", // Custom API URL
155
+ timeout: 5000, // 5s timeout
156
+ retries: 3, // Retry 3 times on fail
157
+ retryDelay: 1000, // Wait 1s between retries
158
+ cache: new RedisCache(), // Implement your own cache if needed!
159
+ });
160
+ ```
161
+
162
+ ---
163
+
164
+ ## 📚 Resources API
165
+
166
+ ### `client.hadiths`
167
+
168
+ - `.get(id)` - Get a single hadith by global ID.
169
+ - `.random(filters)` - Get a random hadith (filter by book, collection, grade).
170
+ - `.daily()` - Get the "Hadith of the Day".
171
+ - `.list(filters)` - Paginated list of hadiths.
172
+
173
+ ### `client.collections`
174
+
175
+ - `.list()` - Get all available collections.
176
+ - `.get(slug)` - Get details about a collection (e.g., `sahih-bukhari`).
177
+
178
+ ### `client.search`
179
+
180
+ - `.query(q, filters)` - Full-text search across translations and Arabic text.
181
+
182
+ ---
183
+
184
+ ## 🚨 Error Handling
185
+
186
+ The SDK throws typed error classes for better handling:
187
+
188
+ ```typescript
189
+ import { AuthenticationError, RateLimitError } from "@mumin/core";
190
+
191
+ try {
192
+ await client.hadiths.get(1);
193
+ } catch (error) {
194
+ if (error instanceof AuthenticationError) {
195
+ // Redirect to login or refresh token
196
+ } else if (error instanceof RateLimitError) {
197
+ // Wait a bit!
198
+ }
199
+ }
200
+ ```
201
+
202
+ ---
203
+
204
+ ## ⚖️ License
205
+
206
+ MIT © Mumin Team
@@ -0,0 +1,35 @@
1
+ import { MuminClient } from '@mumin/core'
2
+
3
+ async function main() {
4
+ console.log('🚀 Initializing MuminClient...')
5
+
6
+ // NOTE: This demo key is just for testing structure, obviously for real requests we need a real key
7
+ // But likely the user will replace it or we use a dummy one if we just want to test compilation
8
+ const client = new MuminClient('sk_mumin_test_key_1234567890abcdef12345678')
9
+
10
+ try {
11
+ console.log('📚 Fetching Collections...')
12
+ const collections = await client.collections.list()
13
+ console.log('RAW COLLECTIONS:', JSON.stringify(collections, null, 2))
14
+
15
+ // Check if it's wrapped in 'data'
16
+ const list = Array.isArray(collections) ? collections : (collections as any).data || []
17
+ console.log(`✅ Found ${list?.length} collections`)
18
+
19
+ console.log('\n🎲 Fetching Random Hadith...')
20
+ const random = await client.hadiths.random()
21
+ console.log('✅ Random Hadith:', random.hadithNumber)
22
+
23
+ // Handle text structure
24
+ const text = random.translation?.text || random.arabicText || 'No text found'
25
+ console.log('Text:', text.substring(0, 50) + '...')
26
+
27
+ } catch (error: any) {
28
+ console.error('❌ Error full details:', JSON.stringify(error, null, 2))
29
+ if (error.response) {
30
+ console.error('Response data:', await error.response.json())
31
+ }
32
+ }
33
+ }
34
+
35
+ main()
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "example-basic",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "ts-node index.ts"
8
+ },
9
+ "dependencies": {
10
+ "@mumin/core": "*",
11
+ "ts-node": "^10.9.2",
12
+ "typescript": "^5.3.3"
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "mumin-api-monorepo",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "workspaces": [
6
+ "packages/*",
7
+ "examples/*"
8
+ ],
9
+ "scripts": {
10
+ "build": "turbo run build",
11
+ "dev": "turbo run dev --parallel",
12
+ "test": "turbo run test",
13
+ "lint": "turbo run lint",
14
+ "type-check": "turbo run type-check",
15
+ "publish": "turbo run build && changeset publish"
16
+ },
17
+ "devDependencies": {
18
+ "@changesets/cli": "^2.27.1",
19
+ "turbo": "^1.11.3",
20
+ "typescript": "^5.3.3"
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ # @mumin/core
2
+
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - initial release
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@mumin-api/core",
3
+ "version": "2.0.0",
4
+ "description": "Official SDK for Mumin Hadith API",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format cjs,esm --dts",
23
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "type-check": "tsc --noEmit",
27
+ "lint": "eslint src --ext .ts"
28
+ },
29
+ "keywords": [
30
+ "hadith",
31
+ "islamic",
32
+ "api",
33
+ "sdk",
34
+ "mumin"
35
+ ],
36
+ "author": "Mumin Development Team",
37
+ "license": "MIT",
38
+ "devDependencies": {
39
+ "@types/node": "^20.11.5",
40
+ "tsup": "^8.0.1",
41
+ "typescript": "^5.3.3",
42
+ "vitest": "^1.2.0",
43
+ "eslint": "^8.56.0"
44
+ }
45
+ }
@@ -0,0 +1,63 @@
1
+ import { CacheAdapter } from '../types'
2
+
3
+ interface CacheEntry<T> {
4
+ value: T
5
+ expiresAt: number
6
+ }
7
+
8
+ export class MemoryCache implements CacheAdapter {
9
+ private store = new Map<string, CacheEntry<any>>()
10
+ private cleanupInterval: NodeJS.Timeout | null = null
11
+
12
+ constructor(cleanupIntervalMs: number = 60000) {
13
+ // Start cleanup logic to avoid memory leaks
14
+ if (typeof setInterval !== 'undefined') {
15
+ this.cleanupInterval = setInterval(() => {
16
+ this.cleanup()
17
+ }, cleanupIntervalMs)
18
+ }
19
+ }
20
+
21
+ async get<T>(key: string): Promise<T | null> {
22
+ const entry = this.store.get(key)
23
+ if (!entry) return null
24
+
25
+ if (Date.now() > entry.expiresAt) {
26
+ this.store.delete(key)
27
+ return null
28
+ }
29
+
30
+ return entry.value as T
31
+ }
32
+
33
+ async set<T>(key: string, value: T, ttl: number): Promise<void> {
34
+ this.store.set(key, {
35
+ value,
36
+ expiresAt: Date.now() + ttl * 1000,
37
+ })
38
+ }
39
+
40
+ async delete(key: string): Promise<void> {
41
+ this.store.delete(key)
42
+ }
43
+
44
+ async clear(): Promise<void> {
45
+ this.store.clear()
46
+ }
47
+
48
+ private cleanup(): void {
49
+ const now = Date.now()
50
+ for (const [key, entry] of this.store.entries()) {
51
+ if (now > entry.expiresAt) {
52
+ this.store.delete(key)
53
+ }
54
+ }
55
+ }
56
+
57
+ // Helper to stop interval if needed (e.g. tests)
58
+ dispose() {
59
+ if (this.cleanupInterval) {
60
+ clearInterval(this.cleanupInterval)
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,48 @@
1
+ import { MuminConfig, CacheAdapter } from './types'
2
+ import { HadithsResource } from './resources/hadiths'
3
+ import { CollectionsResource } from './resources/collections'
4
+ import { SearchResource } from './resources/search'
5
+ import { HTTPClient } from './utils/http'
6
+ import { MemoryCache } from './cache/memory'
7
+
8
+ export class MuminClient {
9
+ private http: HTTPClient
10
+ private cache: CacheAdapter
11
+
12
+ // Resources
13
+ public readonly hadiths: HadithsResource
14
+ public readonly collections: CollectionsResource
15
+ public readonly search: SearchResource
16
+
17
+ constructor(apiKey: string, config?: Partial<MuminConfig>) {
18
+ if (!apiKey) {
19
+ throw new Error('API key is required. Get one at https://dashboard.mumin.ink')
20
+ }
21
+
22
+ const finalConfig: MuminConfig = {
23
+ apiKey,
24
+ baseURL: config?.baseURL || 'https://api.hadith.mumin.ink/v1',
25
+ timeout: config?.timeout || 10000,
26
+ retries: config?.retries || 2,
27
+ retryDelay: config?.retryDelay || 1000,
28
+ cache: config?.cache || new MemoryCache(),
29
+ ...config,
30
+ }
31
+
32
+ this.http = new HTTPClient(finalConfig)
33
+ this.cache = finalConfig.cache
34
+
35
+ this.hadiths = new HadithsResource(this.http, this.cache)
36
+ this.collections = new CollectionsResource(this.http, this.cache)
37
+ this.search = new SearchResource(this.http, this.cache)
38
+ }
39
+
40
+ /**
41
+ * Clear all internal caches
42
+ */
43
+ clearCache(): void {
44
+ this.cache.clear()
45
+ }
46
+ }
47
+
48
+ export default MuminClient
@@ -0,0 +1,4 @@
1
+ export * from './types'
2
+ export * from './client'
3
+ export * from './utils/errors'
4
+ export { MemoryCache } from './cache/memory'
@@ -0,0 +1,29 @@
1
+ import { HTTPClient } from '../utils/http'
2
+ import { CacheAdapter, Collection } from '../types'
3
+
4
+ export class CollectionsResource {
5
+ constructor(
6
+ private http: HTTPClient,
7
+ private cache: CacheAdapter
8
+ ) {}
9
+
10
+ async list(): Promise<Collection[]> {
11
+ const cacheKey = 'collections:all'
12
+ const cached = await this.cache.get<Collection[]>(cacheKey)
13
+ if (cached) return cached
14
+
15
+ const response = await this.http.get<Collection[]>('/collections')
16
+ await this.cache.set(cacheKey, response, 86400) // 24 hours
17
+ return response
18
+ }
19
+
20
+ async get(slug: string): Promise<Collection> {
21
+ const cacheKey = `collection:${slug}`
22
+ const cached = await this.cache.get<Collection>(cacheKey)
23
+ if (cached) return cached
24
+
25
+ const response = await this.http.get<Collection>(`/collections/${slug}`)
26
+ await this.cache.set(cacheKey, response, 86400)
27
+ return response
28
+ }
29
+ }
@@ -0,0 +1,71 @@
1
+ import { HTTPClient } from '../utils/http'
2
+ import { CacheAdapter, Hadith, HadithQuery, PaginatedResponse } from '../types'
3
+
4
+ export class HadithsResource {
5
+ constructor(
6
+ private http: HTTPClient,
7
+ private cache: CacheAdapter
8
+ ) {}
9
+
10
+ /**
11
+ * Get a single hadith by ID
12
+ */
13
+ async get(id: number, options?: { lang?: string }): Promise<Hadith> {
14
+ const cacheKey = `hadith:${id}:${options?.lang || 'default'}`
15
+ const cached = await this.cache.get<Hadith>(cacheKey)
16
+ if (cached) return cached
17
+
18
+ const response = await this.http.get<Hadith>(`/hadiths/${id}`, {
19
+ params: options as Record<string, string>
20
+ })
21
+
22
+ await this.cache.set(cacheKey, response, 3600) // 1 hour
23
+ return response
24
+ }
25
+
26
+ /**
27
+ * Get a random hadith
28
+ */
29
+ async random(options?: HadithQuery): Promise<Hadith> {
30
+ return this.http.get<Hadith>('/hadiths/random', {
31
+ params: options as Record<string, string | number | boolean | undefined>
32
+ })
33
+ }
34
+
35
+ /**
36
+ * Get the daily hadith
37
+ */
38
+ async daily(options?: { lang?: string }): Promise<Hadith> {
39
+ const cacheKey = `daily:${options?.lang || 'default'}`
40
+ const cached = await this.cache.get<Hadith>(cacheKey)
41
+ if (cached) return cached
42
+
43
+ const response = await this.http.get<Hadith>('/hadiths/daily', {
44
+ params: options as Record<string, string>
45
+ })
46
+
47
+ // Cache until next day roughly, or just 1 hour
48
+ await this.cache.set(cacheKey, response, 3600)
49
+ return response
50
+ }
51
+
52
+ /**
53
+ * List/Search hadiths
54
+ */
55
+ async list(options?: HadithQuery): Promise<PaginatedResponse<Hadith>> {
56
+ // If it's a search query, route to /search logic or handle here if supported
57
+ // The spec says 'list' hits /hadiths
58
+ return this.http.get<PaginatedResponse<Hadith>>('/hadiths', {
59
+ params: options as Record<string, string | number | boolean | undefined>
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Search hadiths (Facade for search endpoint if separate)
65
+ */
66
+ async search(query: string, options?: HadithQuery): Promise<PaginatedResponse<Hadith>> {
67
+ return this.http.get<PaginatedResponse<Hadith>>('/hadiths/search', {
68
+ params: { q: query, ...options } as Record<string, string | number | boolean | undefined>
69
+ })
70
+ }
71
+ }
@@ -0,0 +1,22 @@
1
+ import { HTTPClient } from '../utils/http'
2
+ import { CacheAdapter, Hadith, PaginatedResponse, HadithQuery } from '../types'
3
+
4
+ export class SearchResource {
5
+ constructor(
6
+ private http: HTTPClient,
7
+ private cache: CacheAdapter
8
+ ) {}
9
+
10
+ async query(q: string, options?: HadithQuery): Promise<PaginatedResponse<Hadith>> {
11
+ const cacheKey = `search:${q}:${JSON.stringify(options)}`
12
+ const cached = await this.cache.get<PaginatedResponse<Hadith>>(cacheKey)
13
+ if (cached) return cached
14
+
15
+ const response = await this.http.get<PaginatedResponse<Hadith>>('/hadiths/search', {
16
+ params: { q, ...options } as Record<string, string | number | boolean | undefined>
17
+ })
18
+
19
+ await this.cache.set(cacheKey, response, 300) // 5 minutes
20
+ return response
21
+ }
22
+ }
@@ -0,0 +1,110 @@
1
+
2
+ // ============================================
3
+ // CONFIGURATION
4
+ // ============================================
5
+
6
+ export interface MuminConfig {
7
+ apiKey: string
8
+ baseURL: string
9
+ timeout: number
10
+ retries: number
11
+ retryDelay: number
12
+ cache: CacheAdapter
13
+ }
14
+
15
+ // ============================================
16
+ // CACHE
17
+ // ============================================
18
+
19
+ export interface CacheAdapter {
20
+ get<T>(key: string): Promise<T | null>
21
+ set<T>(key: string, value: T, ttl: number): Promise<void>
22
+ delete(key: string): Promise<void>
23
+ clear(): Promise<void>
24
+ }
25
+
26
+ // ============================================
27
+ // HADITH
28
+ // ============================================
29
+
30
+ export interface Hadith {
31
+ id: number
32
+ collectionId: string
33
+ bookNumber: string | number
34
+ chapterId: string | number
35
+ hadithNumber: string
36
+ hadithNumberInt?: number
37
+ label?: string
38
+
39
+ // Content variants
40
+ arabicText?: string
41
+ english?: string
42
+ russian?: string
43
+
44
+ // Translation object found in API response
45
+ translation?: {
46
+ id: number
47
+ text: string
48
+ languageCode: string
49
+ narrator?: string
50
+ }
51
+
52
+ // Computed/Standardized fields (Removed flat 'text' as it is not in API)
53
+ // text: string // Removing this to avoid confusion as it's not in raw response
54
+ }
55
+
56
+ export interface HadithQuery {
57
+ lang?: string
58
+ collection?: string
59
+ book?: number
60
+ grade?: string
61
+ page?: number
62
+ limit?: number
63
+ q?: string // for search
64
+ }
65
+
66
+ // ============================================
67
+ // COLLECTION
68
+ // ============================================
69
+
70
+ export interface Collection {
71
+ slug: string // 'sahih-bukhari'
72
+ name: string
73
+ totalHadiths: number
74
+ description?: string
75
+ }
76
+
77
+ export interface Book {
78
+ id: number | string
79
+ collectionSlug: string
80
+ bookNumber: string
81
+ title: string
82
+ hadithCount?: number
83
+ }
84
+
85
+ export interface Chapter {
86
+ id: number | string
87
+ bookId: number | string
88
+ chapterNumber: string
89
+ title: string
90
+ }
91
+
92
+ // ============================================
93
+ // RESPONSE WRAPPERS
94
+ // ============================================
95
+
96
+ export interface PaginatedResponse<T> {
97
+ data: T[]
98
+ meta: {
99
+ total: number
100
+ page: number
101
+ limit: number
102
+ totalPages: number
103
+ }
104
+ }
105
+
106
+ // ============================================
107
+ // ERROR CLASSES
108
+ // ============================================
109
+
110
+ export { MuminAPIError, AuthenticationError, RateLimitError } from '../utils/errors'
@@ -0,0 +1,41 @@
1
+ export class MuminAPIError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public statusCode: number,
5
+ public details?: any
6
+ ) {
7
+ super(message)
8
+ this.name = 'MuminAPIError'
9
+ }
10
+ }
11
+
12
+ export class AuthenticationError extends MuminAPIError {
13
+ constructor(message: string) {
14
+ super(message, 401)
15
+ this.name = 'AuthenticationError'
16
+ }
17
+ }
18
+
19
+ export class RateLimitError extends MuminAPIError {
20
+ constructor(
21
+ message: string,
22
+ public resetTime?: string | null
23
+ ) {
24
+ super(message, 429)
25
+ this.name = 'RateLimitError'
26
+ }
27
+ }
28
+
29
+ export class ValidationError extends MuminAPIError {
30
+ constructor(message: string, public errors: Record<string, string[]>) {
31
+ super(message, 400, errors)
32
+ this.name = 'ValidationError'
33
+ }
34
+ }
35
+
36
+ export class NotFoundError extends MuminAPIError {
37
+ constructor(message: string) {
38
+ super(message, 404)
39
+ this.name = 'NotFoundError'
40
+ }
41
+ }
@@ -0,0 +1,141 @@
1
+ import { MuminConfig } from '../types'
2
+ import { MuminAPIError, RateLimitError, AuthenticationError, NotFoundError } from './errors'
3
+
4
+ interface RequestOptions {
5
+ params?: Record<string, string | number | boolean | undefined>
6
+ body?: any
7
+ headers?: HeadersInit
8
+ }
9
+
10
+ export class HTTPClient {
11
+ private baseURL: string
12
+ private apiKey: string
13
+ private timeout: number
14
+ private retries: number
15
+ private retryDelay: number
16
+
17
+ constructor(config: MuminConfig) {
18
+ this.baseURL = config.baseURL
19
+ this.apiKey = config.apiKey
20
+ this.timeout = config.timeout
21
+ this.retries = config.retries
22
+ this.retryDelay = config.retryDelay
23
+ }
24
+
25
+ async get<T>(path: string, options?: RequestOptions): Promise<T> {
26
+ return this.request<T>('GET', path, options)
27
+ }
28
+
29
+ async post<T>(path: string, body?: any, options?: RequestOptions): Promise<T> {
30
+ return this.request<T>('POST', path, { ...options, body })
31
+ }
32
+
33
+ private async request<T>(
34
+ method: string,
35
+ path: string,
36
+ options?: RequestOptions
37
+ ): Promise<T> {
38
+ // Ensure no double slashes if path starts with /
39
+ const cleanPath = path.startsWith('/') ? path : `/${path}`
40
+ const url = `${this.baseURL}${cleanPath}`
41
+
42
+ // Filter out undefined params
43
+ const cleanParams: Record<string, string> = {}
44
+ if (options?.params) {
45
+ Object.entries(options.params).forEach(([key, value]) => {
46
+ if (value !== undefined && value !== null) {
47
+ cleanParams[key] = String(value)
48
+ }
49
+ })
50
+ }
51
+
52
+ const queryString = Object.keys(cleanParams).length > 0
53
+ ? '?' + new URLSearchParams(cleanParams).toString()
54
+ : ''
55
+
56
+ const headers: HeadersInit = {
57
+ 'Authorization': `Bearer ${this.apiKey}`,
58
+ 'Content-Type': 'application/json',
59
+ 'User-Agent': `mumin-api-sdk/1.0.0`,
60
+ 'X-Mumin-SDK': '1.0.0', // Custom header to track usage
61
+ ...options?.headers,
62
+ }
63
+
64
+ let lastError: Error | null = null
65
+
66
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
67
+ try {
68
+ const controller = new AbortController()
69
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout)
70
+
71
+ const response = await fetch(url + queryString, {
72
+ method,
73
+ headers,
74
+ body: options?.body ? JSON.stringify(options.body) : undefined,
75
+ signal: controller.signal,
76
+ })
77
+
78
+ clearTimeout(timeoutId)
79
+
80
+ if (!response.ok) {
81
+ await this.handleErrorResponse(response)
82
+ }
83
+
84
+ // Handle 204 No Content
85
+ if (response.status === 204) {
86
+ return {} as T
87
+ }
88
+
89
+ const validResponse = await response.json()
90
+ // Automatically unwrap { success: true, data: ..., meta: ... } response wrapper
91
+ if (validResponse && typeof validResponse === 'object' && 'data' in validResponse) {
92
+ return (validResponse as any).data as T
93
+ }
94
+ return validResponse as T
95
+ } catch (error) {
96
+ lastError = error as Error
97
+
98
+ // Don't retry on 4xx errors (except 429)
99
+ if (error instanceof AuthenticationError || error instanceof NotFoundError) {
100
+ throw error
101
+ }
102
+
103
+ // If it's a 429, we might want to respect retry-after, but for now we just treat it as non-retriable or use standard backoff
104
+ // (Implementation choice: usually we stop on 429 unless we have smart waiting logic)
105
+
106
+ // Wait before retry (exponential backoff)
107
+ if (attempt < this.retries) {
108
+ const delay = this.retryDelay * Math.pow(2, attempt)
109
+ await this.sleep(delay)
110
+ }
111
+ }
112
+ }
113
+
114
+ throw new MuminAPIError(
115
+ `Request failed after ${this.retries + 1} attempts: ${lastError?.message}`,
116
+ 500
117
+ )
118
+ }
119
+
120
+ private async handleErrorResponse(response: Response): Promise<never> {
121
+ let errorData: any
122
+ try {
123
+ errorData = await response.json()
124
+ } catch {
125
+ errorData = { message: response.statusText }
126
+ }
127
+
128
+ const message = errorData.message || errorData.error || 'Unknown error'
129
+ const statusCode = response.status
130
+
131
+ if (statusCode === 401) throw new AuthenticationError(message)
132
+ if (statusCode === 404) throw new NotFoundError(message)
133
+ if (statusCode === 429) throw new RateLimitError(message)
134
+
135
+ throw new MuminAPIError(message, statusCode, errorData)
136
+ }
137
+
138
+ private sleep(ms: number): Promise<void> {
139
+ return new Promise((resolve) => setTimeout(resolve, ms))
140
+ }
141
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "outDir": "dist"
12
+ },
13
+ "include": ["src/**/*"]
14
+ }
@@ -0,0 +1,12 @@
1
+ # @mumin/react
2
+
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - initial release
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @mumin/core@2.0.0
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mumin-api/react",
3
+ "version": "2.0.0",
4
+ "description": "React hooks for Mumin Hadith API",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format cjs,esm --dts --external react",
23
+ "dev": "tsup src/index.ts --format cjs,esm --dts --external react --watch",
24
+ "test": "vitest run",
25
+ "type-check": "tsc --noEmit"
26
+ },
27
+ "peerDependencies": {
28
+ "react": "^18.0.0"
29
+ },
30
+ "dependencies": {
31
+ "@mumin-api/core": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@types/react": "^18.2.48",
35
+ "react": "^18.2.0",
36
+ "tsup": "^8.0.1",
37
+ "typescript": "^5.3.3"
38
+ }
39
+ }
@@ -0,0 +1,43 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { useMuminClient } from '../provider'
3
+ import { Hadith, HadithQuery } from '@mumin/core'
4
+
5
+ export interface UseHadithResult {
6
+ data: Hadith | null
7
+ loading: boolean
8
+ error: Error | null
9
+ refetch: () => Promise<void>
10
+ }
11
+
12
+ export function useHadith(
13
+ id: number | null, // null allowed to defer fetching
14
+ options?: { lang?: string, enabled?: boolean }
15
+ ): UseHadithResult {
16
+ const client = useMuminClient()
17
+ const [data, setData] = useState<Hadith | null>(null)
18
+ const [loading, setLoading] = useState(false)
19
+ const [error, setError] = useState<Error | null>(null)
20
+
21
+ const fetchHadith = async () => {
22
+ if (id === null) return
23
+
24
+ setLoading(true)
25
+ setError(null)
26
+ try {
27
+ const result = await client.hadiths.get(id, { lang: options?.lang })
28
+ setData(result)
29
+ } catch (err: any) {
30
+ setError(err)
31
+ } finally {
32
+ setLoading(false)
33
+ }
34
+ }
35
+
36
+ useEffect(() => {
37
+ if (options?.enabled !== false && id !== null) {
38
+ fetchHadith()
39
+ }
40
+ }, [id, options?.lang, options?.enabled])
41
+
42
+ return { data, loading, error, refetch: fetchHadith }
43
+ }
@@ -0,0 +1,2 @@
1
+ export * from './provider'
2
+ export * from './hooks/useHadith'
@@ -0,0 +1,34 @@
1
+ import React, { createContext, useContext, useMemo } from 'react'
2
+ import { MuminClient, MuminConfig } from '@mumin/core'
3
+
4
+ interface MuminContextValue {
5
+ client: MuminClient
6
+ }
7
+
8
+ const MuminContext = createContext<MuminContextValue | null>(null)
9
+
10
+ export interface MuminProviderProps {
11
+ apiKey: string
12
+ config?: Partial<MuminConfig>
13
+ children: React.ReactNode
14
+ }
15
+
16
+ export function MuminProvider({ apiKey, config, children }: MuminProviderProps) {
17
+ const client = useMemo(() => {
18
+ return new MuminClient(apiKey, config)
19
+ }, [apiKey, config])
20
+
21
+ return (
22
+ <MuminContext.Provider value={{ client }}>
23
+ {children}
24
+ </MuminContext.Provider>
25
+ )
26
+ }
27
+
28
+ export function useMuminClient(): MuminClient {
29
+ const context = useContext(MuminContext)
30
+ if (!context) {
31
+ throw new Error('useMuminClient must be used within MuminProvider')
32
+ }
33
+ return context.client
34
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "jsx": "react-jsx",
12
+ "outDir": "dist"
13
+ },
14
+ "include": ["src/**/*"]
15
+ }
@@ -0,0 +1,12 @@
1
+ # @mumin/vue
2
+
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - initial release
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @mumin/core@2.0.0
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mumin-api/vue",
3
+ "version": "2.0.0",
4
+ "description": "Vue 3 composables for Mumin Hadith API",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format cjs,esm --dts --external vue",
23
+ "dev": "tsup src/index.ts --format cjs,esm --dts --external vue --watch",
24
+ "test": "vitest run",
25
+ "type-check": "vue-tsc --noEmit"
26
+ },
27
+ "peerDependencies": {
28
+ "vue": "^3.0.0"
29
+ },
30
+ "dependencies": {
31
+ "@mumin-api/core": "*"
32
+ },
33
+ "devDependencies": {
34
+ "vue": "^3.4.0",
35
+ "typescript": "^5.3.3",
36
+ "tsup": "^8.0.1",
37
+ "vue-tsc": "^1.8.27"
38
+ }
39
+ }
@@ -0,0 +1,46 @@
1
+ import { ref, watchEffect, Ref, unref } from 'vue'
2
+ import { useMuminClient } from '../plugin'
3
+ import { Hadith } from '@mumin/core'
4
+
5
+ export interface UseHadithOptions {
6
+ lang?: string
7
+ enabled?: Ref<boolean> | boolean
8
+ }
9
+
10
+ export function useHadith(
11
+ id: Ref<number | null> | number | null,
12
+ options?: UseHadithOptions
13
+ ) {
14
+ const client = useMuminClient()
15
+ const data = ref<Hadith | null>(null)
16
+ const loading = ref(false)
17
+ const error = ref<Error | null>(null)
18
+
19
+ const fetchHadith = async () => {
20
+ const hadithId = unref(id)
21
+ if (hadithId === null) return
22
+
23
+ loading.value = true
24
+ error.value = null
25
+
26
+ try {
27
+ const result = await client.hadiths.get(hadithId, { lang: options?.lang })
28
+ data.value = result
29
+ } catch (err: any) {
30
+ error.value = err
31
+ } finally {
32
+ loading.value = false
33
+ }
34
+ }
35
+
36
+ watchEffect(() => {
37
+ const isEnabled = unref(options?.enabled) ?? true
38
+ const hadithId = unref(id)
39
+
40
+ if (isEnabled && hadithId !== null) {
41
+ fetchHadith()
42
+ }
43
+ })
44
+
45
+ return { data, loading, error, refetch: fetchHadith }
46
+ }
@@ -0,0 +1,2 @@
1
+ export * from './plugin'
2
+ export * from './composables/useHadith'
@@ -0,0 +1,24 @@
1
+ import { inject, App, InjectionKey } from 'vue'
2
+ import { MuminClient, MuminConfig } from '@mumin/core'
3
+
4
+ export const MuminClientKey: InjectionKey<MuminClient> = Symbol('MuminClient')
5
+
6
+ export interface MuminPluginOptions {
7
+ apiKey: string
8
+ config?: Partial<MuminConfig>
9
+ }
10
+
11
+ export const MuminPlugin = {
12
+ install(app: App, options: MuminPluginOptions) {
13
+ const client = new MuminClient(options.apiKey, options.config)
14
+ app.provide(MuminClientKey, client)
15
+ }
16
+ }
17
+
18
+ export function useMuminClient(): MuminClient {
19
+ const client = inject(MuminClientKey)
20
+ if (!client) {
21
+ throw new Error('MuminPlugin not installed')
22
+ }
23
+ return client
24
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "outDir": "dist"
12
+ },
13
+ "include": ["src/**/*"]
14
+ }
package/turbo.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://turbo.build/schema.json",
3
+ "pipeline": {
4
+ "build": {
5
+ "dependsOn": ["^build"],
6
+ "outputs": ["dist/**"]
7
+ },
8
+ "test": {
9
+ "dependsOn": ["build"],
10
+ "outputs": [],
11
+ "inputs": ["src/**/*.ts", "test/**/*.ts"]
12
+ },
13
+ "lint": {
14
+ "outputs": []
15
+ },
16
+ "type-check": {
17
+ "outputs": []
18
+ },
19
+ "dev": {
20
+ "cache": false,
21
+ "persistent": true
22
+ }
23
+ }
24
+ }