Skip to content

Commit 09da620

Browse files
committed
feat(notes): expose wikilink parser, link/tag index APIs, and REST CRUD endpoints
- Issue #277: Expose Wikilink parser public types (Wikilink, LinkType, ParsedNote) - Issue #278: Expose NoteIndex public API with get_backlinks/get_forward_links - Issue #279: Expose TagIndex public API with search_by_tag pagination - Issue #280: Add GET /notes/{path}/graph endpoint with depth/max_nodes/tag_filter - Issue #281: Full Notes CRUD REST API: - GET /notes - list notes with prefix filter - GET /notes/{path} - get note with rich metadata - PUT /notes/{path} - create/update note - DELETE /notes/{path} - delete note - POST /notes/{path}/rename - rename with index updates - GET /notes/{path}/backlinks - incoming links - GET /notes/{path}/links - outgoing links - GET /tags - list all tags with counts - GET /tags/{tag}/notes - paginated notes by tag - Add Frontmatter Serialize/Deserialize derives for JSON responses - Add NotesEngine type alias for ergonomic API handler usage - Register NotesEngine as app_data in server startup - All validation: cargo check, clippy, 61 notes tests + full workspace tests pass Closes #277, Closes #278, Closes #279, Closes #280, Closes #281
1 parent 5e58a90 commit 09da620

6 files changed

Lines changed: 517 additions & 91 deletions

File tree

src/api/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ pub mod auth;
44
pub mod config;
55
pub mod graphql;
66
pub mod health;
7+
pub mod notes;
78
pub mod rate_limiter;
89
pub mod timeout_middleware;
910

@@ -315,6 +316,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
315316
.service(health::liveness)
316317
.service(health::readiness)
317318
.service(health::startup)
319+
// Notes & Tags endpoints
320+
.configure(notes::configure)
318321
// GraphQL endpoints
319322
.route("/graphql", web::post().to(graphql_handler))
320323
.route("/graphql", web::get().to(graphql_handler))
@@ -379,6 +382,7 @@ pub async fn start_server(engine: Arc<LsmEngine>, config: ServerConfig) -> std::
379382
let token_manager = web::Data::new(TokenManager::new_with_engine(engine.clone()));
380383
let auth_enabled = web::Data::new(config.auth.enabled);
381384
let graphql_schema = web::Data::new(graphql::build_schema(engine.clone()));
385+
let note_engine = web::Data::new(crate::notes::NoteEngine::new(engine.clone()));
382386

383387
let cors_enabled = config.cors_enabled;
384388
let cors_origins = config.cors_origins.clone();
@@ -401,6 +405,7 @@ pub async fn start_server(engine: Arc<LsmEngine>, config: ServerConfig) -> std::
401405
.app_data(token_manager.clone())
402406
.app_data(auth_enabled.clone())
403407
.app_data(graphql_schema.clone())
408+
.app_data(note_engine.clone())
404409
.app_data(access_controller.clone())
405410
.app_data(access_control_enabled.clone())
406411
.configure(configure)

src/api/notes.rs

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
//! Notes CRUD and graph REST API endpoints.
2+
//!
3+
//! Provides the following endpoints:
4+
//!
5+
//! - `GET /notes` — List all notes
6+
//! - `GET /notes/{path}` — Get a note
7+
//! - `PUT /notes/{path}` — Create or update a note
8+
//! - `DELETE /notes/{path}` — Delete a note
9+
//! - `POST /notes/{path}/rename` — Rename a note
10+
//! - `GET /notes/{path}/backlinks` — List backlinks
11+
//! - `GET /notes/{path}/links` — List forward links
12+
//! - `GET /notes/{path}/graph` — Graph view data
13+
//! - `GET /tags` — List all tags
14+
//! - `GET /tags/{tag}/notes` — List notes by tag
15+
16+
use crate::infra::error::LsmError;
17+
use crate::notes::{GraphConfig, GraphDepth, NotesEngine};
18+
use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, Responder};
19+
use serde::Deserialize;
20+
use serde_json::json;
21+
22+
/// Query parameters for `GET /notes`
23+
#[derive(Deserialize)]
24+
pub struct ListNotesQuery {
25+
prefix: Option<String>,
26+
}
27+
28+
/// Request body for `PUT /notes/{path}`
29+
#[derive(Deserialize)]
30+
pub struct PutNoteBody {
31+
content: String,
32+
}
33+
34+
/// Query parameters for `GET /notes/{path}/graph`
35+
#[derive(Deserialize)]
36+
pub struct GraphQuery {
37+
depth: Option<usize>,
38+
max_nodes: Option<usize>,
39+
tag_filter: Option<String>,
40+
}
41+
42+
/// Query parameters for `GET /tags/{tag}/notes`
43+
#[derive(Deserialize)]
44+
pub struct TagNotesQuery {
45+
cursor: Option<String>,
46+
limit: Option<usize>,
47+
}
48+
49+
/// Request body for `POST /notes/{path}/rename`
50+
#[derive(Deserialize)]
51+
pub struct RenameBody {
52+
new_path: String,
53+
}
54+
55+
// ── Handlers ────────────────────────────────────────────────────────────────
56+
57+
/// `GET /notes` — list all notes with optional prefix filter.
58+
#[get("")]
59+
async fn list_notes(
60+
req: HttpRequest,
61+
engine: web::Data<NotesEngine>,
62+
query: web::Query<ListNotesQuery>,
63+
) -> impl Responder {
64+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
65+
return e;
66+
}
67+
match engine.list_notes(query.prefix.as_deref()) {
68+
Ok(notes) => HttpResponse::Ok()
69+
.content_type("application/json")
70+
.json(json!({ "notes": notes })),
71+
Err(e) => {
72+
tracing::error!(target: "apexstore::api::notes", "Failed to list notes: {:?}", e);
73+
HttpResponse::InternalServerError()
74+
.content_type("application/json")
75+
.json(json!({ "error": "internal server error" }))
76+
}
77+
}
78+
}
79+
80+
/// `GET /notes/{path}` — get a single note.
81+
#[get("/{path}")]
82+
async fn get_note(
83+
req: HttpRequest,
84+
engine: web::Data<NotesEngine>,
85+
path: web::Path<String>,
86+
) -> impl Responder {
87+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
88+
return e;
89+
}
90+
let note_path = path.into_inner();
91+
match engine.get_note(&note_path) {
92+
Ok(Some(content)) => {
93+
// Parse to return rich data
94+
let parsed = crate::notes::parse_note(&content);
95+
let backlinks = engine.get_backlinks(&note_path).unwrap_or_default();
96+
let forward = engine.get_forward_links(&note_path).unwrap_or_default();
97+
98+
HttpResponse::Ok()
99+
.content_type("application/json")
100+
.json(json!({
101+
"path": note_path,
102+
"content": content,
103+
"frontmatter": parsed.frontmatter,
104+
"links": forward,
105+
"backlinks": backlinks,
106+
"tags": parsed.inline_tags,
107+
}))
108+
}
109+
Ok(None) => HttpResponse::NotFound()
110+
.content_type("application/json")
111+
.json(json!({ "error": "note not found" })),
112+
Err(e) => {
113+
tracing::error!(target: "apexstore::api::notes", "Failed to get note: {:?}", e);
114+
HttpResponse::InternalServerError()
115+
.content_type("application/json")
116+
.json(json!({ "error": "internal server error" }))
117+
}
118+
}
119+
}
120+
121+
/// `PUT /notes/{path}` — create or update a note.
122+
#[put("/{path}")]
123+
async fn put_note(
124+
req: HttpRequest,
125+
engine: web::Data<NotesEngine>,
126+
path: web::Path<String>,
127+
body: web::Json<PutNoteBody>,
128+
) -> impl Responder {
129+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Write) {
130+
return e;
131+
}
132+
let note_path = path.into_inner();
133+
match engine.put_note(&note_path, &body.content) {
134+
Ok(_) => HttpResponse::Ok()
135+
.content_type("application/json")
136+
.json(json!({ "status": "ok", "path": note_path })),
137+
Err(e) => {
138+
tracing::error!(target: "apexstore::api::notes", "Failed to put note: {:?}", e);
139+
HttpResponse::InternalServerError()
140+
.content_type("application/json")
141+
.json(json!({ "error": "internal server error" }))
142+
}
143+
}
144+
}
145+
146+
/// `DELETE /notes/{path}` — delete a note.
147+
#[delete("/{path}")]
148+
async fn delete_note(
149+
req: HttpRequest,
150+
engine: web::Data<NotesEngine>,
151+
path: web::Path<String>,
152+
) -> impl Responder {
153+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Delete) {
154+
return e;
155+
}
156+
let note_path = path.into_inner();
157+
match engine.delete_note(&note_path) {
158+
Ok(_) => HttpResponse::Ok()
159+
.content_type("application/json")
160+
.json(json!({ "status": "ok" })),
161+
Err(e) => {
162+
tracing::error!(target: "apexstore::api::notes", "Failed to delete note: {:?}", e);
163+
HttpResponse::InternalServerError()
164+
.content_type("application/json")
165+
.json(json!({ "error": "internal server error" }))
166+
}
167+
}
168+
}
169+
170+
/// `POST /notes/{path}/rename` — rename a note.
171+
#[post("/{path}/rename")]
172+
async fn rename_note(
173+
req: HttpRequest,
174+
engine: web::Data<NotesEngine>,
175+
path: web::Path<String>,
176+
body: web::Json<RenameBody>,
177+
) -> impl Responder {
178+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Write) {
179+
return e;
180+
}
181+
let old_path = path.into_inner();
182+
match engine.rename_note(&old_path, &body.new_path) {
183+
Ok(_) => HttpResponse::Ok()
184+
.content_type("application/json")
185+
.json(json!({ "status": "ok", "old_path": old_path, "new_path": body.new_path })),
186+
Err(LsmError::InvalidArgument(msg)) => HttpResponse::NotFound()
187+
.content_type("application/json")
188+
.json(json!({ "error": msg })),
189+
Err(e) => {
190+
tracing::error!(target: "apexstore::api::notes", "Failed to rename note: {:?}", e);
191+
HttpResponse::InternalServerError()
192+
.content_type("application/json")
193+
.json(json!({ "error": "internal server error" }))
194+
}
195+
}
196+
}
197+
198+
/// `GET /notes/{path}/backlinks` — list notes linking TO this note.
199+
#[get("/{path}/backlinks")]
200+
async fn get_backlinks(
201+
req: HttpRequest,
202+
engine: web::Data<NotesEngine>,
203+
path: web::Path<String>,
204+
) -> impl Responder {
205+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
206+
return e;
207+
}
208+
let note_path = path.into_inner();
209+
match engine.get_backlinks(&note_path) {
210+
Ok(links) => HttpResponse::Ok()
211+
.content_type("application/json")
212+
.json(json!({ "backlinks": links })),
213+
Err(e) => {
214+
tracing::error!(target: "apexstore::api::notes", "Failed to get backlinks: {:?}", e);
215+
HttpResponse::InternalServerError()
216+
.content_type("application/json")
217+
.json(json!({ "error": "internal server error" }))
218+
}
219+
}
220+
}
221+
222+
/// `GET /notes/{path}/links` — list notes linked FROM this note.
223+
#[get("/{path}/links")]
224+
async fn get_forward_links(
225+
req: HttpRequest,
226+
engine: web::Data<NotesEngine>,
227+
path: web::Path<String>,
228+
) -> impl Responder {
229+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
230+
return e;
231+
}
232+
let note_path = path.into_inner();
233+
match engine.get_forward_links(&note_path) {
234+
Ok(links) => HttpResponse::Ok()
235+
.content_type("application/json")
236+
.json(json!({ "links": links })),
237+
Err(e) => {
238+
tracing::error!(target: "apexstore::api::notes", "Failed to get forward links: {:?}", e);
239+
HttpResponse::InternalServerError()
240+
.content_type("application/json")
241+
.json(json!({ "error": "internal server error" }))
242+
}
243+
}
244+
}
245+
246+
/// `GET /notes/{path}/graph` — graph view data.
247+
#[get("/{path}/graph")]
248+
async fn get_graph(
249+
req: HttpRequest,
250+
engine: web::Data<NotesEngine>,
251+
path: web::Path<String>,
252+
query: web::Query<GraphQuery>,
253+
) -> impl Responder {
254+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
255+
return e;
256+
}
257+
let note_path = path.into_inner();
258+
259+
// Build graph config from query params
260+
let depth = match query.depth.unwrap_or(1) {
261+
0 => GraphDepth::Direct,
262+
1 => GraphDepth::Direct,
263+
2 => GraphDepth::Extended,
264+
_ => GraphDepth::Deep,
265+
};
266+
let max_nodes = query.max_nodes.unwrap_or(500).min(500);
267+
let config = GraphConfig {
268+
depth,
269+
max_nodes,
270+
tag_filter: query.tag_filter.clone(),
271+
include_tags: true,
272+
include_isolated: false,
273+
};
274+
275+
match engine.build_graph(&note_path, &config) {
276+
Ok(graph_data) => HttpResponse::Ok()
277+
.content_type("application/json")
278+
.json(json!({
279+
"root": graph_data.root,
280+
"nodes": graph_data.nodes,
281+
"edges": graph_data.edges,
282+
})),
283+
Err(e) => {
284+
tracing::error!(target: "apexstore::api::notes", "Failed to build graph: {:?}", e);
285+
HttpResponse::InternalServerError()
286+
.content_type("application/json")
287+
.json(json!({ "error": "internal server error" }))
288+
}
289+
}
290+
}
291+
292+
/// `GET /tags` — list all tags.
293+
#[get("/tags")]
294+
async fn list_tags(req: HttpRequest, engine: web::Data<NotesEngine>) -> impl Responder {
295+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
296+
return e;
297+
}
298+
match engine.list_tags() {
299+
Ok(tags) => {
300+
let tags_json: Vec<serde_json::Value> = tags
301+
.into_iter()
302+
.map(|(name, count)| json!({ "tag": name, "count": count }))
303+
.collect();
304+
HttpResponse::Ok()
305+
.content_type("application/json")
306+
.json(json!({ "tags": tags_json }))
307+
}
308+
Err(e) => {
309+
tracing::error!(target: "apexstore::api::notes", "Failed to list tags: {:?}", e);
310+
HttpResponse::InternalServerError()
311+
.content_type("application/json")
312+
.json(json!({ "error": "internal server error" }))
313+
}
314+
}
315+
}
316+
317+
/// `GET /tags/{tag}/notes` — list notes with a specific tag.
318+
#[get("/tags/{tag}/notes")]
319+
async fn get_notes_by_tag(
320+
req: HttpRequest,
321+
engine: web::Data<NotesEngine>,
322+
path: web::Path<String>,
323+
query: web::Query<TagNotesQuery>,
324+
) -> impl Responder {
325+
if let Err(e) = crate::api::require_permission(&req, crate::api::Permission::Read) {
326+
return e;
327+
}
328+
let tag = path.into_inner();
329+
match engine.search_by_tag(&tag, query.cursor.as_deref(), query.limit.unwrap_or(50)) {
330+
Ok((notes, next_cursor)) => {
331+
HttpResponse::Ok()
332+
.content_type("application/json")
333+
.json(json!({
334+
"tag": tag,
335+
"notes": notes,
336+
"cursor": next_cursor,
337+
}))
338+
}
339+
Err(e) => {
340+
tracing::error!(target: "apexstore::api::notes", "Failed to get notes by tag: {:?}", e);
341+
HttpResponse::InternalServerError()
342+
.content_type("application/json")
343+
.json(json!({ "error": "internal server error" }))
344+
}
345+
}
346+
}
347+
348+
// ── Route configuration ─────────────────────────────────────────────────────
349+
350+
/// Register all notes API routes under `/notes` and `/tags`.
351+
pub fn configure(cfg: &mut web::ServiceConfig) {
352+
cfg.service(
353+
web::scope("/notes")
354+
.service(list_notes)
355+
.service(get_note)
356+
.service(put_note)
357+
.service(delete_note)
358+
.service(rename_note)
359+
.service(get_backlinks)
360+
.service(get_forward_links)
361+
.service(get_graph),
362+
)
363+
.service(list_tags)
364+
.service(get_notes_by_tag);
365+
}

0 commit comments

Comments
 (0)