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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/README.md +206 -0
- package/examples/basic/index.ts +35 -0
- package/examples/basic/package.json +14 -0
- package/package.json +22 -0
- package/packages/core/CHANGELOG.md +7 -0
- package/packages/core/package.json +45 -0
- package/packages/core/src/cache/memory.ts +63 -0
- package/packages/core/src/client.ts +48 -0
- package/packages/core/src/index.ts +4 -0
- package/packages/core/src/resources/collections.ts +29 -0
- package/packages/core/src/resources/hadiths.ts +71 -0
- package/packages/core/src/resources/search.ts +22 -0
- package/packages/core/src/types/index.ts +110 -0
- package/packages/core/src/utils/errors.ts +41 -0
- package/packages/core/src/utils/http.ts +141 -0
- package/packages/core/tsconfig.json +14 -0
- package/packages/react/CHANGELOG.md +12 -0
- package/packages/react/package.json +39 -0
- package/packages/react/src/hooks/useHadith.ts +43 -0
- package/packages/react/src/index.ts +2 -0
- package/packages/react/src/provider.tsx +34 -0
- package/packages/react/tsconfig.json +15 -0
- package/packages/vue/CHANGELOG.md +12 -0
- package/packages/vue/package.json +39 -0
- package/packages/vue/src/composables/useHadith.ts +46 -0
- package/packages/vue/src/index.ts +2 -0
- package/packages/vue/src/plugin.ts +24 -0
- package/packages/vue/tsconfig.json +14 -0
- package/turbo.json +24 -0
|
@@ -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
|
+

|
|
7
|
+

|
|
8
|
+

|
|
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()
|
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,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,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,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,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,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,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
|
+
}
|