From 8bc62f641cf6980bcbc91120f20108cd14b50845 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Sun, 7 Sep 2025 21:46:47 +0200 Subject: [PATCH] feat: small articles webpage --- .gitignore | 3 +- articles/Makefile | 13 ++ articles/run.sh | 21 +-- articles/server_c.c | 312 ++++++++++++++++++++++++++++++++++++ articles/test_server_api.py | 119 ++++++++------ 5 files changed, 401 insertions(+), 67 deletions(-) create mode 100644 articles/Makefile create mode 100644 articles/server_c.c diff --git a/.gitignore b/.gitignore index 4138b58..7ff42b5 100644 --- a/.gitignore +++ b/.gitignore @@ -246,4 +246,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml -fps_demo \ No newline at end of file +fps_demo +server_c \ No newline at end of file diff --git a/articles/Makefile b/articles/Makefile new file mode 100644 index 0000000..8b9213f --- /dev/null +++ b/articles/Makefile @@ -0,0 +1,13 @@ +CC ?= cc +CFLAGS ?= -O2 -Wall -Wextra -pedantic +LDFLAGS ?= + +all: server_c + +server_c: server_c.c + $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) + +clean: + rm -f server_c + +.PHONY: all clean diff --git a/articles/run.sh b/articles/run.sh index 1e67dc8..41d1060 100755 --- a/articles/run.sh +++ b/articles/run.sh @@ -1,32 +1,19 @@ #!/usr/bin/env bash set -euo pipefail -# Run Mini Articles (backend + frontend) +# Run Mini Articles (backend + frontend) using the C server only # Options (env): HOST (default 127.0.0.1), PORT (default 8000), ARTICLES_DATA_DIR DIR=$(cd -- "$(dirname -- "$0")" && pwd) SITE_DIR="$DIR" HOST="${HOST:-127.0.0.1}" PORT="${PORT:-8000}" -PYTHON_BIN="${PYTHON:-}" -if [[ -z "${PYTHON_BIN}" ]]; then - if command -v python >/dev/null 2>&1; then PYTHON_BIN=python - elif command -v python3 >/dev/null 2>&1; then PYTHON_BIN=python3 - else - echo "Python is required but not found in PATH." >&2 - exit 1 - fi -fi +make -s -C "$SITE_DIR" server_c -if [[ ! -f "$SITE_DIR/server.py" ]]; then - echo "server.py not found in $SITE_DIR" >&2 - exit 1 -fi - -# Start server in background +# Start C server in background export HOST PORT ARTICLES_DATA_DIR -"$PYTHON_BIN" "$SITE_DIR/server.py" & +"$SITE_DIR/server_c" & SRV_PID=$! trap 'kill $SRV_PID 2>/dev/null || true' EXIT INT TERM diff --git a/articles/server_c.c b/articles/server_c.c new file mode 100644 index 0000000..931fdbc --- /dev/null +++ b/articles/server_c.c @@ -0,0 +1,312 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define RECV_BUF 65536 +#define SMALL_BUF 4096 + +static volatile sig_atomic_t g_stop = 0; +static const char *DOC_ROOT = NULL; // current working directory + +static void on_sigint(int sig){ (void)sig; g_stop = 1; } + +static long long now_ms(){ struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); return (long long)ts.tv_sec*1000 + ts.tv_nsec/1000000; } + +static const char* getenv_default(const char* k, const char* def){ const char* v=getenv(k); return v&&*v? v: def; } + +// ---- file helpers ---- +static int ensure_dir(const char* path){ struct stat st; if(stat(path,&st)==0){ if(S_ISDIR(st.st_mode)) return 0; errno=ENOTDIR; return -1; } if(mkdir(path,0775)==0) return 0; return -1; } + +static char* path_join(const char* a, const char* b){ size_t la=strlen(a), lb=strlen(b); size_t n=la+1+lb+1; char* r=malloc(n); if(!r) return NULL; snprintf(r,n,"%s/%s",a,b); return r; } + +static char* data_dir(){ const char* env = getenv("ARTICLES_DATA_DIR"); if(env && *env){ ensure_dir(env); return strdup(env); } char* d = path_join(DOC_ROOT?DOC_ROOT: ".", "data"); if(d){ ensure_dir(d); } return d; } + +static char* data_file(){ char* d = data_dir(); if(!d) return NULL; char* f = path_join(d, "articles.json"); free(d); return f; } + +// Read entire file into memory (NUL-terminated). Caller frees. +static char* read_file_all(const char* path, size_t* out_len){ FILE* f=fopen(path,"rb"); if(!f){ if(out_len) *out_len=0; return NULL; } fseek(f,0,SEEK_END); long sz=ftell(f); if(sz<0) sz=0; fseek(f,0,SEEK_SET); char* buf=malloc((size_t)sz+1); if(!buf){ fclose(f); return NULL; } size_t n=fread(buf,1,(size_t)sz,f); fclose(f); buf[n]='\0'; if(out_len) *out_len=n; return buf; } + +static int write_file_all(const char* path, const char* data, size_t len){ FILE* f=fopen(path,"wb"); if(!f) return -1; size_t n=fwrite(data,1,len,f); fclose(f); return n==len?0:-1; } + +static int append_file_line(const char* path, const char* line){ FILE* f=fopen(path,"ab"); if(!f) return -1; size_t n=fwrite(line,1,strlen(line),f); n+=fwrite("\n",1,1,f); fclose(f); return (int)n>=0?0:-1; } + +// ---- JSON helpers (minimal) ---- +static char* json_escape(const char* s){ size_t n=0; for(const char* p=s; *p; ++p){ if(*p=='"'||*p=='\\'||*p=='\n'||*p=='\r'||*p=='\t') n+=2; else n++; } + char* out=malloc(n+1); if(!out) return NULL; char* w=out; for(const char* p=s; *p; ++p){ if(*p=='"'){ *w++='\\'; *w++='"'; } else if(*p=='\\'){ *w++='\\'; *w++='\\'; } else if(*p=='\n'){ *w++='\\'; *w++='n'; } else if(*p=='\r'){ *w++='\\'; *w++='r'; } else if(*p=='\t'){ *w++='\\'; *w++='t'; } else { *w++=*p; } } *w='\0'; return out; } + +// Find string field value for key in compact JSON. Returns malloc'd string (unescaped). Empty string if not found. +static char* json_get_string(const char* json, const char* key){ + // Build quoted key pattern if needed + const char* qkey = key; + char* tmp = NULL; + size_t klen = strlen(key); + if(klen==0) return strdup(""); + if(key[0] != '"'){ + tmp = malloc(klen + 3); + if(!tmp) return strdup(""); + tmp[0] = '"'; memcpy(tmp+1, key, klen); tmp[klen+1] = '"'; tmp[klen+2] = '\0'; + qkey = tmp; klen += 2; + } + const char* p = json; + while((p = strstr(p, qkey))){ + const char* colon = strchr(p + klen, ':'); if(!colon){ p += klen; continue; } + const char* v = colon + 1; while(*v==' '||*v=='\t') v++; + if(*v!='"'){ if(tmp) free(tmp); return strdup(""); } + v++; // inside string + char* out = malloc(strlen(v)+1); if(!out){ if(tmp) free(tmp); return strdup(""); } + size_t w=0; bool esc=false; for(const char* q=v; *q; ++q){ char c=*q; if(esc){ if(c=='"'||c=='\\'||c=='/'){ out[w++]=c; } else if(c=='n'){ out[w++]='\n'; } else if(c=='r'){ out[w++]='\r'; } else if(c=='t'){ out[w++]='\t'; } else { out[w++]=c; } esc=false; continue; } if(c=='\\'){ esc=true; continue; } if(c=='"'){ out[w]='\0'; if(tmp) free(tmp); return out; } out[w++]=c; } + free(out); if(tmp) free(tmp); return strdup(""); + } + if(tmp) free(tmp); return strdup(""); +} + +static long long json_get_number(const char* json, const char* key){ const char* p = strstr(json, key); if(!p) return 0; const char* colon=strchr(p,':'); if(!colon) return 0; const char* v=colon+1; while(*v==' '||*v=='\t') v++; return atoll(v); } + +// Parse a top-level JSON object and return the string value for a given key (unquoted key name). Caller frees. +static char* json_get_top_string(const char* json, const char* key){ + size_t keylen = strlen(key); + const char* p = json; + // seek to first '{' + while(*p && *p!='{') p++; + if(*p!='{') return strdup(""); + p++; + while(*p){ + while(*p==' '||*p=='\n'||*p=='\r'||*p=='\t'||*p==',') p++; + if(*p=='}' || !*p) break; + if(*p!='"'){ // invalid + // skip token to next comma or end + while(*p && *p!=',' && *p!='}') p++; + if(*p==',') { p++; continue; } else break; + } + // parse key string + p++; const char* ks=p; size_t klen=0; bool esc=false; for(; *p; ++p){ char c=*p; if(esc){ esc=false; continue; } if(c=='\\'){ esc=true; continue; } if(c=='"'){ break; } klen++; } + const char* key_start=ks; const char* key_end=p; if(*p=='"') p++; while(*p==' '||*p=='\t') p++; if(*p!=':') { // malformed + while(*p && *p!=',' && *p!='}') p++; if(*p==',') { p++; continue; } else break; + } + p++; while(*p==' '||*p=='\t') p++; + int key_match = (klen==keylen) && (strncmp(key_start, key, keylen)==0); + if(*p=='"'){ + p++; // read string value + char* out = malloc(strlen(p)+1); if(!out) return strdup(""); size_t w=0; bool e=false; for(; *p; ++p){ char c=*p; if(e){ if(c=='"'||c=='\\'||c=='/'){ out[w++]=c; } else if(c=='n'){ out[w++]='\n'; } else if(c=='r'){ out[w++]='\r'; } else if(c=='t'){ out[w++]='\t'; } else { out[w++]=c; } e=false; continue; } if(c=='\\'){ e=true; continue; } if(c=='"'){ out[w]='\0'; p++; break; } out[w++]=c; } + if(key_match){ return out; } else { free(out); } + } else { + // non-string value: skip until comma or end (simple) + while(*p && *p!=',' && *p!='}') p++; + } + if(*p==','){ p++; } + } + return strdup(""); +} + +// Build object JSON string; caller frees +static char* build_article_json(const char* id, const char* title, const char* body, const char* thumb, long long createdAt, long long updatedAt){ + char *et=json_escape(title?title:""), *eb=json_escape(body?body:""), *eth=json_escape(thumb?thumb:""); + if(!et||!eb||!eth){ free(et); free(eb); free(eth); return NULL; } + char createdBuf[64]; snprintf(createdBuf,sizeof(createdBuf),"%lld",createdAt); + char updated[96]=""; if(updatedAt>0){ snprintf(updated,sizeof(updated),",\"updatedAt\":%lld",updatedAt); } + size_t need = strlen(id)+strlen(et)+strlen(eb)+strlen(eth)+strlen(createdBuf)+strlen(updated)+64; + char* out=malloc(need); + if(!out){ free(et); free(eb); free(eth); return NULL; } + snprintf(out, need, "{\"id\":\"%s\",\"title\":\"%s\",\"body\":\"%s\",\"thumb\":\"%s\",\"createdAt\":%s%s}", id, et, eb, eth, createdBuf, updated); + free(et); free(eb); free(eth); return out; +} + +static char* gen_id(){ char* out=malloc(17); if(!out) return NULL; unsigned int r = (unsigned int)rand(); long long t=now_ms(); snprintf(out,17,"%08x%08x", (unsigned int)(t&0xffffffff), r); return out; } + +// ---- HTTP helpers ---- +static void send_str(int c, const char* s){ send(c, s, strlen(s), 0); } + +static void send_response(int c, int code, const char* status, const char* ctype, const char* body, size_t blen, bool cors){ + char head[SMALL_BUF]; + int n=snprintf(head,sizeof(head), + "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %zu\r\n%s\r\n", + code, status, ctype?ctype:"text/plain", blen, + cors? "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n":""); + send(c, head, (size_t)n, 0); + if(body && blen) send(c, body, blen, 0); +} + +static const char* guess_mime(const char* path){ const char* ext=strrchr(path,'.'); if(!ext) return "application/octet-stream"; ext++; if(!strcmp(ext,"html")) return "text/html; charset=utf-8"; if(!strcmp(ext,"css")) return "text/css"; if(!strcmp(ext,"js")) return "application/javascript"; if(!strcmp(ext,"png")) return "image/png"; if(!strcmp(ext,"jpg")||!strcmp(ext,"jpeg")) return "image/jpeg"; if(!strcmp(ext,"svg")) return "image/svg+xml"; return "application/octet-stream"; } + +// ---- Data operations (NDJSON-style internal), exposed as JSON array ---- +static char* ltrim_dup(const char* s){ while(*s && isspace((unsigned char)*s)) s++; return strdup(s); } + +static char* list_articles_json(size_t* out_len){ + char* file=data_file(); if(!file){ return strdup("[]"); } + size_t n=0; char* content=read_file_all(file,&n); free(file); + if(!content||n==0){ free(content); if(out_len) *out_len=2; return strdup("[]"); } + char* t=ltrim_dup(content); free(content); + if(!t){ if(out_len) *out_len=2; return strdup("[]"); } + // If already a JSON array, return as-is + if(t[0]=='['){ if(out_len) *out_len=strlen(t); return t; } + // Otherwise, treat as empty + free(t); if(out_len) *out_len=2; return strdup("[]"); +} + +static char* find_article_by_id(const char* id){ + char* file=data_file(); if(!file) return NULL; + size_t n=0; char* content=read_file_all(file,&n); free(file); if(!content) return NULL; + char* t=ltrim_dup(content); free(content); if(!t) return NULL; if(t[0] != '['){ free(t); return NULL; } + // iterate objects + size_t len=strlen(t); size_t i=1; int depth=0; size_t start=0; for(; i=2 && tcontent[0]=='[' && tcontent[clen-1]==']'){ + // insert at front + bool empty = (clen==2); + size_t Lobj=strlen(obj); + size_t newlen = 1 + Lobj + (empty?0:1) + (clen-2) + 1; + char* out=malloc(newlen+1); size_t w=0; out[w++]='['; memcpy(out+w,obj,Lobj); w+=Lobj; if(!empty){ out[w++]=','; memcpy(out+w, tcontent+1, clen-2); w+=(clen-2); } out[w++]=']'; out[w]='\0'; write_file_all(file, out, w); free(out); + } else { // corrupt; overwrite + size_t L=strlen(obj); size_t tot=L+2; char* arr=malloc(tot+1); size_t w=0; arr[w++]='['; memcpy(arr+w,obj,L); w+=L; arr[w++]=']'; arr[w]='\0'; write_file_all(file, arr, w); free(arr); + } + } else { + // not an array; overwrite + size_t L=strlen(obj); size_t tot=L+2; char* arr=malloc(tot+1); size_t w=0; arr[w++]='['; memcpy(arr+w,obj,L); w+=L; arr[w++]=']'; arr[w]='\0'; write_file_all(file, arr, w); free(arr); + } + free(tcontent); + } + free(content); free(file); free(id); return obj; } + +// ---- request handling ---- +static void handle_api(int c, const char* method, const char* path, const char* body, size_t blen){ + if(!strncmp(method,"OPTIONS",7)){ + send_response(c, 204, "No Content", "application/json", "", 0, true); return; + } + // /api/articles or /api/articles/ + const char* base = "/api/articles"; + size_t bl = strlen(base); + if(!strncmp(path, base, bl)){ + if(!strcmp(method,"GET")){ + if(!strcmp(path, base)){ + size_t L=0; char* arr=list_articles_json(&L); send_response(c, 200, "OK", "application/json", arr, L, true); free(arr); return; + } else if(path[bl]=='/' && strlen(path)>bl+1){ + const char* id=path+bl+1; char* obj=find_article_by_id(id); if(!obj){ send_response(c,404,"Not Found","application/json","",0,true); return;} size_t L=strlen(obj); send_response(c,200,"OK","application/json",obj,L,true); free(obj); return; + } + } else if(!strcmp(method,"POST") && !strcmp(path, base)){ + char* obj=create_article_from_body(body?body:""); if(!obj){ send_response(c,400,"Bad Request","application/json","",0,true); return;} size_t L=strlen(obj); send_response(c,201,"Created","application/json",obj,L,true); free(obj); return; + } else if(!strcmp(method,"PUT") && path[bl]=='/' && strlen(path)>bl+1){ + const char* id=path+bl+1; char* updated=NULL; if(rewrite_articles_map(&updated,id,body?body:"",false)==0){ size_t L=strlen(updated); send_response(c,200,"OK","application/json",updated,L,true); free(updated); } else { send_response(c,404,"Not Found","application/json","",0,true);} return; + } else if(!strcmp(method,"DELETE") && path[bl]=='/' && strlen(path)>bl+1){ + const char* id=path+bl+1; if(rewrite_articles_map(NULL,id,NULL,true)==0){ send_response(c,204,"No Content","application/json","",0,true);} else { send_response(c,404,"Not Found","application/json","",0,true);} return; + } + } + send_response(c,404,"Not Found","text/plain","Not Found",9,true); +} + +static bool safe_path(const char* p){ if(strstr(p, "..")) return false; return true; } + +static void handle_static(int c, const char* path){ + char rel[SMALL_BUF]; if(!strcmp(path,"/")) strncpy(rel,"/index.html",sizeof(rel)); else strncpy(rel,path,sizeof(rel)); rel[sizeof(rel)-1]='\0'; + if(!safe_path(rel)){ send_response(c,403,"Forbidden","text/plain","Forbidden",9,false); return; } + // build abs path + char full[SMALL_BUF*2]; snprintf(full,sizeof(full),"%s%s", DOC_ROOT?DOC_ROOT:".", rel); + FILE* f=fopen(full,"rb"); if(!f){ send_response(c,404,"Not Found","text/plain","Not Found",9,false); return; } + fseek(f,0,SEEK_END); long sz=ftell(f); fseek(f,0,SEEK_SET); char* buf=malloc((size_t)sz); if(!buf){ fclose(f); send_response(c,500,"Internal Server Error","text/plain","",0,false); return; } + size_t n=fread(buf,1,(size_t)sz,f); fclose(f); + const char* mime=guess_mime(full); + send_response(c,200,"OK",mime,buf,n,false); free(buf); +} + +static void handle_client(int c){ + char buf[RECV_BUF]; ssize_t rcv=0, total=0; + // read headers + while((rcv=recv(c, buf+total, sizeof(buf)-1-total, 0))>0){ total+=rcv; buf[total]='\0'; if(strstr(buf, "\r\n\r\n")) break; if(total >= (ssize_t)sizeof(buf)-1) break; } + if(total<=0){ close(c); return; } + // parse request line + char method[16]={0}, path[SMALL_BUF]={0}; + sscanf(buf, "%15s %1023s", method, path); + // headers + size_t content_length=0; char* cl = strcasestr(buf, "Content-Length:"); if(cl){ content_length = strtoul(cl+15, NULL, 10); } + // find body start + char* hdr_end = strstr(buf, "\r\n\r\n"); size_t header_bytes = hdr_end? (size_t)(hdr_end - buf) + 4 : (size_t)total; size_t have_body = total > (ssize_t)header_bytes ? (size_t)total - header_bytes : 0; + char* body = NULL; if(content_length){ body = malloc(content_length+1); if(!body){ close(c); return; } size_t off=0; if(have_body){ size_t cpy = have_body>content_length?content_length:have_body; memcpy(body, buf+header_bytes, cpy); off = cpy; } + size_t remain = content_length - off; while(remain>0){ ssize_t rr = recv(c, body+off, remain, 0); if(rr<=0) break; off+=rr; remain-=rr; } body[content_length]='\0'; } + + if(!strncmp(path, "/api/", 5)){ + handle_api(c, method, path, body, content_length); + } else if(!strcmp(method,"GET")){ + handle_static(c, path); + } else if(!strcmp(method,"OPTIONS")){ + send_response(c,204,"No Content","text/plain","",0,false); + } else { + send_response(c,405,"Method Not Allowed","text/plain","",0,false); + } + free(body); + close(c); +} + +int main(int argc, char** argv){ + signal(SIGINT, on_sigint); + srand((unsigned int)time(NULL)); + char cwd[SMALL_BUF]; if(getcwd(cwd,sizeof(cwd))) DOC_ROOT=strdup(cwd); + const char* host = getenv_default("HOST","127.0.0.1"); + int port = atoi(getenv_default("PORT","8000")); if(port<=0) port=8000; + + int s = socket(AF_INET, SOCK_STREAM, 0); if(s<0){ perror("socket"); return 1; } + int opt=1; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + struct sockaddr_in addr; memset(&addr,0,sizeof(addr)); addr.sin_family=AF_INET; addr.sin_port=htons((uint16_t)port); addr.sin_addr.s_addr=inet_addr(host); + if(bind(s,(struct sockaddr*)&addr,sizeof(addr))<0){ perror("bind"); close(s); return 1; } + if(listen(s,64)<0){ perror("listen"); close(s); return 1; } + printf("Serving Mini Articles (C) on http://%s:%d\n", host, port); + + while(!g_stop){ struct sockaddr_in ca; socklen_t calen=sizeof(ca); int c=accept(s,(struct sockaddr*)&ca,&calen); if(c<0){ if(errno==EINTR) break; perror("accept"); continue; } handle_client(c); } + close(s); + return 0; +} diff --git a/articles/test_server_api.py b/articles/test_server_api.py index e44b668..3e945ac 100644 --- a/articles/test_server_api.py +++ b/articles/test_server_api.py @@ -3,12 +3,9 @@ import os import time import urllib.request import urllib.error - -SITE_DIR = os.path.join(os.path.dirname(__file__), '..', 'site') -import sys -sys.path.insert(0, SITE_DIR) - -from server import make_server # type: ignore +import subprocess +import socket +from pathlib import Path def _req(url, method="GET", data=None): @@ -22,51 +19,75 @@ def _req(url, method="GET", data=None): def test_crud_roundtrip(tmp_path): - # Isolate storage - os.environ["ARTICLES_DATA_DIR"] = str(tmp_path) + # Build C server + here = Path(__file__).resolve().parent + subprocess.run(["make", "-s", "server_c"], check=True, cwd=str(here)) - httpd, th = make_server(port=0) - host, port = httpd.server_address + # Find a free port + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + _, port = s.getsockname() + host = "127.0.0.1" base = f"http://{host}:{port}" - # Create - code, body = _req(base+"/api/articles", method="POST", data={ - "title": "T1", - "body": "

Hello

", - "thumb": "data:image/png;base64,xyz" - }) - assert code == 201 - created = json.loads(body) - art_id = created["id"] - - # List - code, body = _req(base+"/api/articles") - assert code == 200 - items = json.loads(body) - assert any(a["id"] == art_id for a in items) - - # Get one - code, body = _req(base+f"/api/articles/{art_id}") - assert code == 200 - got = json.loads(body) - assert got["title"] == "T1" - - # Update - code, body = _req(base+f"/api/articles/{art_id}", method="PUT", data={"title": "T2"}) - assert code == 200 - updated = json.loads(body) - assert updated["title"] == "T2" - - # Delete - code, _ = _req(base+f"/api/articles/{art_id}", method="DELETE") - assert code == 204 - - # Ensure gone + # Isolate storage and start server + env = os.environ.copy() + env["ARTICLES_DATA_DIR"] = str(tmp_path) + env["HOST"] = host + env["PORT"] = str(port) + srv = subprocess.Popen(["./server_c"], cwd=str(here), env=env) try: - _req(base+f"/api/articles/{art_id}") - assert False, "Expected 404" - except urllib.error.HTTPError as e: - assert e.code == 404 + # wait briefly for server to be ready + for _ in range(30): + try: + with urllib.request.urlopen(base + "/api/articles", timeout=0.2) as resp: + resp.read() + break + except Exception: + time.sleep(0.05) - httpd.shutdown() - th.join(timeout=2) + # Create + code, body = _req(base+"/api/articles", method="POST", data={ + "title": "T1", + "body": "

Hello

", + "thumb": "data:image/png;base64,xyz" + }) + assert code == 201 + created = json.loads(body) + art_id = created["id"] + + # List + code, body = _req(base+"/api/articles") + assert code == 200 + items = json.loads(body) + assert any(a["id"] == art_id for a in items) + + # Get one + code, body = _req(base+f"/api/articles/{art_id}") + assert code == 200 + got = json.loads(body) + assert got["title"] == "T1" + + # Update + code, body = _req(base+f"/api/articles/{art_id}", method="PUT", data={"title": "T2"}) + assert code == 200 + updated = json.loads(body) + assert updated["title"] == "T2" + + # Delete + code, _ = _req(base+f"/api/articles/{art_id}", method="DELETE") + assert code == 204 + + # Ensure gone + try: + _req(base+f"/api/articles/{art_id}") + assert False, "Expected 404" + except urllib.error.HTTPError as e: + assert e.code == 404 + + finally: + srv.terminate() + try: + srv.wait(timeout=2) + except Exception: + srv.kill()