Skip to content

Commit b79723f

Browse files
committed
first commit
1 parent 603f9ea commit b79723f

File tree

9 files changed

+508
-0
lines changed

9 files changed

+508
-0
lines changed

.gitignore

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# ======================
2+
# Build and distribution
3+
# ======================
4+
build/
5+
dist/
6+
*.egg-info/
7+
.eggs/
8+
*.egg
9+
10+
# ======================
11+
# Byte-compiled / cache files
12+
# ======================
13+
__pycache__/
14+
*.py[cod]
15+
*.pyo
16+
*.pyd
17+
*.so
18+
19+
# ======================
20+
# Local environment files
21+
# ======================
22+
.env
23+
*.env
24+
.env.*
25+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Not Empty Free Software Foundation
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# grit-requester
2+
3+
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com)
4+
5+
**grit-requester-js** is a javascript library to abstract requests to microservices built using Grit.
6+
7+
Features:
8+
9+
- 🔁 Automatic retry on `401 Unauthorized`
10+
- 🔐 Per-service token cache with concurrency safety
11+
- 💉 Config and HTTP client injection (perfect for testing)
12+
- 📦 Full support for generics (`any`) in request/response
13+
- 🧠 Context-aware: all requests support context.Context for cancellation, timeouts, and APM tracing
14+
15+
---
16+
17+
## ✨ Installation
18+
19+
```bash
20+
npm install "https://github.com/not-empty/grit-requester-js/releases/download/v1.0.2/grit-requester-js-1.0.2.tgz"
21+
```
22+
23+
---
24+
25+
## 🚀 Usage Example
26+
27+
### Configure and do a request
28+
```ts
29+
import { GritRequester } from 'grit-requester-js';
30+
31+
interface IUser {
32+
id: string;
33+
name: string;
34+
email: string;
35+
}
36+
37+
// configure grit requester
38+
const ms = new GritRequester({
39+
baseUrl: 'http://localhost:8001',
40+
token: process.env.SERVICE_TOKEN || '',
41+
secret: process.env.SERVICE_SECRET || '',
42+
context: process.env.SERVICE_CONTEXT || '',
43+
});
44+
45+
// doing a request
46+
const result = await ms.request<{ id: string }>({
47+
path: '/user/add',
48+
method: 'POST',
49+
body: {
50+
name: 'example',
51+
email: 'example@example.com'
52+
}
53+
});
54+
55+
```
56+
57+
### Make crud requests from a domain
58+
59+
Here you can call a domain passing the type and path to access the following base routers:
60+
61+
| Path | Description |
62+
| -----------------| -------------------------------------------|
63+
| add | Create a new record |
64+
| bulk | Fetch specific records by IDs |
65+
| bulkAdd | Create up to 25 records in the same request|
66+
| deadDetail | Get a deleted record by ID |
67+
| deadList | List deleted records (paginated) |
68+
| delete | Soft-delete a record by ID |
69+
| detail | Get an active record by ID |
70+
| edit | Update specific fields |
71+
| list | List active records (paginated) |
72+
| listOne | List one record based on params |
73+
| selectRaw | Execute a predefined raw SQL query safely |
74+
75+
```ts
76+
import { GritRequester } from 'grit-requester-js';
77+
78+
interface IUser {
79+
id: string;
80+
name: string;
81+
email: string;
82+
}
83+
84+
// configure grit requester
85+
const ms = new GritRequester({
86+
baseUrl: 'http://localhost:8001',
87+
token: process.env.SERVICE_TOKEN || '',
88+
secret: process.env.SERVICE_SECRET || '',
89+
context: process.env.SERVICE_CONTEXT || '',
90+
});
91+
92+
// make a request from domain
93+
const resultFile = await ms.domain<IUser>('user').add({
94+
name: 'example',
95+
email: 'example@example.com'
96+
});
97+
98+
```
99+
---
100+
101+
## 🧪 Testing
102+
103+
Run tests:
104+
105+
```bash
106+
npm run test
107+
```
108+
109+
Run test coverage
110+
```bash
111+
npm run coverage:
112+
```
113+
114+
Visualize unit coverage:
115+
116+
```bash
117+
open ./coverage/unit/lcov-report/index.html
118+
```
119+
120+
Visualize feature coverage:
121+
122+
```bash
123+
open ./coverage/feature/lcov-report/index.html
124+
```
125+
126+
## 🔧 License
127+
128+
MIT © [Not Empty](https://github.com/not-empty)
129+
130+
**Not Empty Foundation - Free codes, full minds**

grit_requester/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .service import GritService, GritConfig
2+
3+
__all__ = ["GritService", "GritConfig"]

grit_requester/config.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class GritConfig:
2+
def __init__(
3+
self,
4+
base_url: str,
5+
auth_url: str,
6+
token: str,
7+
secret: str,
8+
context: str,
9+
token_header: str = "x-refreshed-token",
10+
token_expiration_header: str = "x-refreshed-token-valid-until"
11+
):
12+
self.base_url = base_url
13+
self.auth_url = auth_url
14+
self.token = token
15+
self.secret = secret
16+
self.context = context
17+
self.token_header = token_header
18+
self.token_expiration_header = token_expiration_header
19+
20+
def __repr__(self):
21+
return f"<GritConfig base_url={self.base_url!r} context={self.context!r}>"

grit_requester/domain.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from typing import List, Dict, Any, Optional
2+
from .query import prepare_grit_query
3+
4+
class GritDomain:
5+
def __init__(self, service, path: str):
6+
self.api = service.session
7+
self.base_url = service.base_url.rstrip("/")
8+
self.path = path.strip("/")
9+
10+
def _url(self, endpoint: str = "") -> str:
11+
return f"{self.base_url}/{self.path}/{endpoint}".rstrip("/")
12+
13+
def detail(self, id: str) -> Optional[Dict[str, Any]]:
14+
try:
15+
resp = self.api.get(self._url(f"detail/{id}"))
16+
resp.raise_for_status()
17+
return resp.json()
18+
except Exception as e:
19+
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 404:
20+
return None
21+
raise
22+
23+
def dead_detail(self, id: str) -> Optional[Dict[str, Any]]:
24+
try:
25+
resp = self.api.get(self._url(f"detail/{id}"))
26+
resp.raise_for_status()
27+
return resp.json()
28+
except Exception as e:
29+
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 404:
30+
return None
31+
raise
32+
33+
def list(self, filters: Optional[List[Dict[str, Any]]] = None,
34+
order: Optional[Dict[str, str]] = None,
35+
cursor: Optional[str] = None) -> Dict[str, Any]:
36+
37+
query = prepare_grit_query({
38+
"filters": filters,
39+
"order": order,
40+
"cursor": cursor
41+
})
42+
43+
url = f"{self._url('list')}?{query}" if query else self._url('list')
44+
resp = self.api.get(url)
45+
resp.raise_for_status()
46+
47+
return {
48+
"data": resp.json(),
49+
"cursor": resp.headers.get('x-page-cursor', '')
50+
}
51+
52+
def dead_list(self, filters: Optional[List[Dict[str, Any]]] = None,
53+
order: Optional[Dict[str, str]] = None,
54+
cursor: Optional[str] = None) -> Dict[str, Any]:
55+
56+
query = prepare_grit_query({
57+
"filters": filters,
58+
"order": order,
59+
"cursor": cursor
60+
})
61+
62+
url = f"{self._url('dead_list')}?{query}" if query else self._url('list')
63+
resp = self.api.get(url)
64+
resp.raise_for_status()
65+
66+
return {
67+
"data": resp.json(),
68+
"cursor": resp.headers.get('x-page-cursor', '')
69+
}
70+
71+
def list_all(self, filters: Optional[List[Dict[str, Any]]] = None,
72+
order: Optional[str] = None) -> List[Dict[str, Any]]:
73+
data = []
74+
cursor = None
75+
76+
while True:
77+
result = self.list(filters=filters, order=order, cursor=cursor)
78+
data.extend(result["data"])
79+
80+
if not result["cursor"] or cursor == result["cursor"]:
81+
break
82+
83+
cursor = result["cursor"]
84+
85+
return data
86+
87+
def list_one(self, filters: Optional[List[Dict[str, Any]]] = None,
88+
order: Optional[Dict[str, str]] = None) -> Optional[Dict[str, Any]]:
89+
query = prepare_grit_query({
90+
"filters": filters,
91+
"order": order
92+
})
93+
url = f"{self._url('list_one')}?{query}"
94+
resp = self.api.get(url)
95+
resp.raise_for_status()
96+
97+
result = resp.json()
98+
99+
if not result or not isinstance(result, dict) or not result.keys():
100+
return None
101+
102+
return result
103+
104+
def add(self, payload: Dict[str, Any]) -> Dict[str, Any]:
105+
resp = self.api.post(self._url("add"), json=payload)
106+
resp.raise_for_status()
107+
return resp.json()
108+
109+
def bulk_add(self, payload: List[Dict[str, Any]]) -> Dict[str, List[str]]:
110+
chunk_size = 25
111+
results = {"ids": []}
112+
113+
for i in range(0, len(payload), chunk_size):
114+
chunk = payload[i:i + chunk_size]
115+
response = self.api.post(self._url("bulk_add"), json=chunk)
116+
response.raise_for_status()
117+
data = response.json()
118+
results["ids"].extend(data.get("ids", []))
119+
120+
return results
121+
122+
def edit(self, id: str, data: Dict[str, Any]) -> Dict[str, Any]:
123+
resp = self.api.patch(self._url(f"edit/{id}"), json=data)
124+
resp.raise_for_status()
125+
return resp.json()
126+
127+
def delete(self, id: str):
128+
resp = self.api.delete(self._url(f"delete/{id}"))
129+
resp.raise_for_status()
130+
131+
def bulk(self, ids: List[str]) -> List[Dict[str, Any]]:
132+
resp = self.api.post(self._url("bulk"), json={"ids": ids})
133+
resp.raise_for_status()
134+
return resp.json()
135+
136+
def bulk_all(self, ids: List[str]) -> List[Dict[str, Any]]:
137+
results = []
138+
size = 25
139+
140+
for i in range(0, len(ids), size):
141+
chunk = ids[i:i + size]
142+
res = self.bulk(chunk)
143+
results.extend(res)
144+
145+
return results
146+
147+
def select_raw(self, query: str, params: Dict[str, Any]) -> Dict[str, Any]:
148+
resp = self.api.post(self._url("select_raw"), json={ "query": query, "params": params })
149+
resp.raise_for_status()
150+
return resp.json()

grit_requester/query.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from typing import Optional, List, Dict, Any
2+
3+
def prepare_grit_filter(filters: Optional[List[Dict[str, Any]]]) -> List[str]:
4+
if not filters:
5+
return []
6+
return [
7+
f"filter={f['field']}:{f.get('type', 'eql')}:{f['value']}"
8+
for f in filters
9+
]
10+
11+
def prepare_grit_order(order: Optional[Dict[str, str]]) -> List[str]:
12+
if not order:
13+
return []
14+
return [
15+
f"order_by={order['field']}",
16+
f"order={order['type']}"
17+
]
18+
19+
def prepare_grit_query(payload: Optional[Dict[str, Any]]) -> str:
20+
if not payload:
21+
return ""
22+
23+
query: List[str] = []
24+
25+
query.extend(prepare_grit_filter(payload.get("filters")))
26+
query.extend(prepare_grit_order(payload.get("order")))
27+
28+
cursor = payload.get("cursor")
29+
if cursor:
30+
query.append(f"page_cursor={cursor}")
31+
32+
return "&".join(query)

0 commit comments

Comments
 (0)