Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ Built with **FastAPI & Pydantic v2** and fewer than 100 SLOC of business code.
### Quick start

```bash
git clone https://github.com/your‑org/phonebook‑rpc.git
cd phonebook‑rpc
python -m venv .venv && source .venv/bin/activate # Windows: .venv\\Scripts\\activate
pip install -r requirements.txt
uvicorn app.main:app --reload # http://127.0.0.1:5000
Expand All @@ -35,12 +33,14 @@ tests/

### JSON‑RPC API

| Method | Params | Result / Error |
|-------------------|-----------------------------------------------------|----------------|
| **AddContact** | `name:str`, `phone:str`, `email:str\|null` | `Contact` |
| **GetByName** | `name:str` | `Contact[]` |
| **UpdateContact** | `id:str`, `name:str`, `phone:str`, `email:str|null` | `Contact` |
| **DeleteContact** | `id:str` | `null` |

| Method | Params | Result / Error |
|--------------------|-----------------------------------------------------|---------------------------------|
| **AddContact** | `name:str`, `phone:str`, `email:str\|null` | `Contact` |
| **GetByName** | `name:str` | `Contact[]` |
| **GetAllContacts** | _none_ | `Dict[str, List[str]]` |
| **UpdateContact** | `id:str`, `name:str`, `phone:str`, `email:str\|null` | `Contact` |
| **DeleteContact** | `id:str` | `null` |

> *All phone numbers must match E.164: `+` and 7–15 digits.*
> Duplicate (`name + phone`) raises `"code":‑32602`.
Expand Down Expand Up @@ -100,6 +100,16 @@ tests/
}
```

**5. Get all contacts:**
```json
{
"jsonrpc":"2.0",
"method":"GetAllContacts",
"params":{},
"id":5
}
```

---

### Running the tests
Expand All @@ -125,14 +135,6 @@ TOTAL 125 0 100%

---

### Why JSON‑RPC instead of REST?

* single `/rpc` endpoint; method name lives in the payload
* symmetrical request / response with formal spec (OpenRPC‑ready)
* thin ~20‑line dispatcher, **no** external dependency (`fastapi-jsonrpc` not required)

---

### Requirements

* Python 3.10+
Expand Down
7 changes: 7 additions & 0 deletions app/PhoneBook.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ async def get_by_name(self, name: str) -> List[Contact]:
logger.debug("GetByName '%s' -> %d records", name, len(contacts))
return contacts

async def get_all(self) -> Dict[str, List[str]]:
async with self._lock:
result: Dict[str, List[str]] = {}
for name, ids in self._by_name.items():
result[name] = [self._by_id[i] for i in ids]
return result

async def update(self, id: str, name: str, phone: str, email: Optional[str] = None) -> Contact:
async with self._lock:
if id not in self._by_id:
Expand Down
17 changes: 13 additions & 4 deletions app/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ async def _json_success(result: Any, id_: Optional[str]) -> Dict[str, Any]:


AUTHORISATION_ERROR_CODES = {
KeyError: (-32602, "Not found"),
ValueError: (-32602, "Invalid params"),
KeyError: (-32602, "Not found"),
ValueError: (-32602, None),
}


Expand Down Expand Up @@ -58,14 +58,23 @@ async def rpc_entrypoint(request: Request) -> Dict[str, Any]:
await phonebook.delete(params["id"])
resp = await _json_success(None, id_)

elif method == "GetAllContacts":
all_contacts = await phonebook.get_all()
resp = await _json_success(all_contacts, id_)

else:
resp = {"jsonrpc": "2.0", "error": {"code": -32601, "message": f"Unknown method {method!r}"}, "id": id_}

log.debug("=> %s", resp)
return resp

except Exception as exc:
code, message = AUTHORISATION_ERROR_CODES.get(type(exc), (-32000, str(exc)))
resp = {"jsonrpc": "2.0", "error": {"code": code, "message": message}, "id": id_}
code, default_msg = AUTHORISATION_ERROR_CODES.get(type(exc), (-32000, None))
message = default_msg if default_msg is not None else str(exc)
resp = {
"jsonrpc": "2.0",
"error": {"code": code, "message": message},
"id": id_,
}
log.warning("! %s -> %s", method, message)
return resp
3 changes: 1 addition & 2 deletions devops/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ ENV PYTHONUNBUFFERED=1 \

EXPOSE 8000

CMD ["uvicorn", "--factory", "--workers", "4", \
"--host", "0.0.0.0", "--port", "8000", "app.main:app"]
CMD ["uvicorn", "--workers", "4", "--host", "0.0.0.0", "--port", "8000", "app.main:app"]
38 changes: 38 additions & 0 deletions tests/test_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,41 @@ async def test_invalid_jsonrpc_version(client):
assert data["error"]["code"] == -32600
assert data["id"] is None


@pytest.mark.asyncio
async def test_get_all_contacts(client):
# Добавим двух пользователей
await client.post("/rpc", json={
"jsonrpc": "2.0",
"method": "AddContact",
"params": {"name": "Bob", "phone": "+79990000001"},
"id": 1
})
await client.post("/rpc", json={
"jsonrpc": "2.0",
"method": "AddContact",
"params": {"name": "Charlie", "phone": "+79990000002"},
"id": 2
})

# Получим всех
res = await client.post("/rpc", json={
"jsonrpc": "2.0",
"method": "GetAllContacts",
"params": {},
"id": 3
})
data = res.json()

assert "result" in data
result = data["result"]
assert isinstance(result, dict)
assert "bob" in result or "charlie" in result # имя преобразуется к lowercase
for name, contacts in result.items():
assert isinstance(name, str)
assert isinstance(contacts, list)
for contact in contacts:
assert "id" in contact
assert "name" in contact
assert "phone" in contact