From 88a0fb9db6bbabdbb9bbf25c51b6d2050687749b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 9 Mar 2026 09:51:56 +0400 Subject: [PATCH] feat: notes-python CRUD example + runtime path/query forwarding - invoke.go: forward sub-path and query string to function pods - server.js v0.1.2: add _path, _query, _method to event - server.py v0.1.1: add _path, _query, _method to event - upload.go: bump runtime versions (nodejs20:v0.1.2, python3.11:v0.1.1) - examples/notes-python: CRUD notes via sub-path routing - sql-runner: generic SQL executor for DDL jobs - notes: CRUD router (/add, /update, /delete) - notes-list: SELECT all notes - init.tf: create TABLE + INDEX on apply --- .../notes-python/code/notes-list/handler.py | 22 ++++++ .../code/notes-list/requirements.txt | 1 + examples/notes-python/code/notes/handler.py | 74 ++++++++++++++++++ .../notes-python/code/notes/requirements.txt | 1 + .../notes-python/code/sql-runner/handler.py | 26 ++++++ .../code/sql-runner/requirements.txt | 1 + examples/notes-python/dist/notes-list.zip | Bin 0 -> 746 bytes examples/notes-python/dist/notes.zip | Bin 0 -> 1226 bytes examples/notes-python/dist/sql-runner.zip | Bin 0 -> 796 bytes examples/notes-python/init.tf | 34 ++++++++ examples/notes-python/main.tf | 21 +++++ examples/notes-python/notes-list.tf | 32 ++++++++ examples/notes-python/notes.tf | 35 +++++++++ examples/notes-python/outputs.tf | 12 +++ examples/notes-python/sql-runner.tf | 26 ++++++ examples/notes-python/variables.tf | 9 +++ internal/api/handler/invoke.go | 23 ++++-- internal/api/handler/upload.go | 4 +- runtimes/nodejs20/server.js | 14 +++- runtimes/python3.11/server.py | 20 ++++- 20 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 examples/notes-python/code/notes-list/handler.py create mode 100644 examples/notes-python/code/notes-list/requirements.txt create mode 100644 examples/notes-python/code/notes/handler.py create mode 100644 examples/notes-python/code/notes/requirements.txt create mode 100644 examples/notes-python/code/sql-runner/handler.py create mode 100644 examples/notes-python/code/sql-runner/requirements.txt create mode 100644 examples/notes-python/dist/notes-list.zip create mode 100644 examples/notes-python/dist/notes.zip create mode 100644 examples/notes-python/dist/sql-runner.zip create mode 100644 examples/notes-python/init.tf create mode 100644 examples/notes-python/main.tf create mode 100644 examples/notes-python/notes-list.tf create mode 100644 examples/notes-python/notes.tf create mode 100644 examples/notes-python/outputs.tf create mode 100644 examples/notes-python/sql-runner.tf create mode 100644 examples/notes-python/variables.tf diff --git a/examples/notes-python/code/notes-list/handler.py b/examples/notes-python/code/notes-list/handler.py new file mode 100644 index 0000000..84a0cf4 --- /dev/null +++ b/examples/notes-python/code/notes-list/handler.py @@ -0,0 +1,22 @@ +# 2026-03-09 +# handler.py — возвращает все записи из таблицы notes. +# GET/POST /fn/default/notes-list → JSON массив записей, сортировка по created_at DESC. +import os +import psycopg2 +import psycopg2.extras + + +def handle(event): + dsn = os.environ['PG_DSN'] + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT id, title, body, created_at::text FROM notes ORDER BY created_at DESC" + ) + rows = cur.fetchall() + return [dict(r) for r in rows] + except Exception as e: + return {'error': str(e)} + finally: + conn.close() diff --git a/examples/notes-python/code/notes-list/requirements.txt b/examples/notes-python/code/notes-list/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/examples/notes-python/code/notes-list/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/examples/notes-python/code/notes/handler.py b/examples/notes-python/code/notes/handler.py new file mode 100644 index 0000000..cce026f --- /dev/null +++ b/examples/notes-python/code/notes/handler.py @@ -0,0 +1,74 @@ +# 2026-03-09 +# handler.py — CRUD роутер для таблицы notes. +# Роутинг по event._path (sub-path URL): +# POST /fn/default/notes/add?title=...&body=... → INSERT +# POST /fn/default/notes/update?id=1&title=... → UPDATE +# POST /fn/default/notes/delete?id=1 → DELETE +# _path и _query добавляет runtime из HTTP запроса (server.py). +import os +import psycopg2 +import psycopg2.extras + + +def handle(event): + dsn = os.environ['PG_DSN'] + # sub-path без ведущего слэша: "add", "update", "delete" + action = event.get('_path', '/').strip('/') + q = event.get('_query', {}) + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + + if action == 'add': + title = q.get('title') or event.get('title', '') + body = q.get('body') or event.get('body', '') + if not title: + return {'error': 'title is required'} + cur.execute( + "INSERT INTO notes (title, body) VALUES (%s, %s)" + " RETURNING id, title, body, created_at::text", + (title, body) + ) + row = cur.fetchone() + conn.commit() + return dict(row) + + elif action == 'update': + id_ = q.get('id') or event.get('id') + if not id_: + return {'error': 'id is required'} + title = q.get('title') or event.get('title', '') + body = q.get('body') or event.get('body', '') + cur.execute( + "UPDATE notes SET title=%s, body=%s WHERE id=%s" + " RETURNING id, title, body, created_at::text", + (title, body, int(id_)) + ) + row = cur.fetchone() + conn.commit() + return dict(row) if row else {'error': 'not found'} + + elif action == 'delete': + id_ = q.get('id') or event.get('id') + if not id_: + return {'error': 'id is required'} + cur.execute( + "DELETE FROM notes WHERE id=%s RETURNING id", + (int(id_),) + ) + row = cur.fetchone() + conn.commit() + return {'deleted': row['id']} if row else {'error': 'not found'} + + else: + return { + 'error': f'unknown action: /{action}', + 'hint': 'use /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X' + } + + except Exception as e: + conn.rollback() + return {'error': str(e)} + finally: + conn.close() diff --git a/examples/notes-python/code/notes/requirements.txt b/examples/notes-python/code/notes/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/examples/notes-python/code/notes/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/examples/notes-python/code/sql-runner/handler.py b/examples/notes-python/code/sql-runner/handler.py new file mode 100644 index 0000000..2b39304 --- /dev/null +++ b/examples/notes-python/code/sql-runner/handler.py @@ -0,0 +1,26 @@ +# 2026-03-09 +# handler.py — универсальный исполнитель SQL запросов. +# Принимает event.statements — массив SQL строк, выполняет последовательно. +# Используется sless_job для DDL операций (CREATE TABLE, миграции и т.д.) +import os +import psycopg2 + + +def handle(event): + dsn = os.environ['PG_DSN'] + statements = event.get('statements', []) + if not statements: + return {'error': 'no statements provided'} + + conn = psycopg2.connect(dsn) + try: + cur = conn.cursor() + for sql in statements: + cur.execute(sql) + conn.commit() + return {'ok': True, 'executed': len(statements)} + except Exception as e: + conn.rollback() + return {'error': str(e)} + finally: + conn.close() diff --git a/examples/notes-python/code/sql-runner/requirements.txt b/examples/notes-python/code/sql-runner/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/examples/notes-python/code/sql-runner/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary diff --git a/examples/notes-python/dist/notes-list.zip b/examples/notes-python/dist/notes-list.zip new file mode 100644 index 0000000000000000000000000000000000000000..1e3b0367130cc3e103e500a5a5cd44a4521569bb GIT binary patch literal 746 zcmWIWW@Zs#-~d7f2E{HQ0S8<_Rz_l8N=|B#UO{Ed#QR;h{6vnw|18q}^$?3mm;Db% z*8Ej3oieYx?$z|3SWyzF51 z1K#O>4n*&Mp!;E(dBXIVPv-wlMpx(CH;1&SebFkA?3KFy_aTSW${dv$$6Wn*mtXsQ zM!@D?edF`P|3p9N>%C3le{xl`{_Oqi1o1tai+4X+JNI*6_@8F)2kiG6FGvWhJ)W^Z zoOk!a>3e5u_%1kee|zUlPSeWUR_~qXOzP`>Udg$ve%kkse5$Ou{0s_)LRJfWIzH)L z_PQ6cUjO*shoN7$PHvr4{kP-xca0PNJv?(7zr0~6dgtVP{A|mj3vQYJ1CC|tc)W~! z{wCrf!{a^EIbT^t7jSVEb}dr)(l7u0{857{!BY1Ls-_Eiiq;&Io_Z!rU2yB|mf)uJ z=6|OT^E%dF=w|!y&gY!IhxsMn zQ)j$&Ry|>2fth(duHmN`PzOj?fHxzP2s0vmA=?K^Uofzw5yV2IHgtW+W`fLxfhCPx ZK$oMtEx?)ux=gUPlCxO>w}=g=GNC!VLy ztVvn>q~n|Wtuyjx-`uNycc=JVUmB~Kx!L~6`#-8`AKyO3^~POf=?wN}#TuLc%yL_+ zeNUXfn)}acf578EGFPfgC;w;r*($ZtyL9oo&*ne%9cCU;TCt|#*XEABVf8D^7uccIB|0;IeFoAj`p9 za?Z}Sj0``1JS)GEReGqbJhINrVd4>fj;xxkW=9)6>+-k%+#s!Y+TG%)tKF3LbNOwz z7oOZ+yE6NQeA*0tQ^)PEf_kq+`%aO1E`GpR>m(yv>DqMx&XYL`ZHCf&Zp?{i|Kakt4@>^)ch~8k*WD-kpV_YrlwBq0 z6e_=JTDa|Io>1Nh5${7KmaD&U>%<-pc>LqR6&ZbjNs}8_FIW3lpm$}$^B)CO&q941 z&a7ygZMOQl#WX#kD@q=ATXJW;=y5*$gr)Ep>*IBQU#bV{NXnZ%^WeT^;Wb-jTE9<3 zU@-qApX$s8VZ|qRLJd>5PF=v*UiLWcWyOIXIo97+^7~zUo01`Z|LLo%POB<+%{Wm zyYar+4z>&CvK1c=AATEmgRwmH#|EuEuaua!9L`&R_g0oITT{)9liw7dSxl1nsvNoA zyGCjCKc~8tF)wo~kKWvP&GxZlQ|g}c8fB*$uL-?*&~Rd>Ics{ykxNhCR2*Zn(4Bq2 zs_q;+%h&f)R5Bdh|Guze<$U#D_%BD4(!Kvr4nCc?Q9Ubhy5EhhyK^$SXB@5$J<#ue zsEmWfH0x1zI20^hAAZNe$8d;aPvP_zU-^>qQIS#>>}S zzxQ)rK(*WTNgdx;ng|~Izvt8(u{BD|*Ik^Kxo_V5zuq&C+zGTk822KpYl?+rt>;Zc z-7jmM-(ER)ZjP(boLZ-)>*jOv%-`|hhe7e=%3Wpo2kwSGT^XzuRMPC9WHUivmi*gY z9tV7~+kDPlD$(zXKX+m011ZIw-L|i%wp`HgZ7WfkfOEZg7b5rw5iuFn=O0+cA`JB`DFu&w`>WsI}swYe=Ff*^m zHT)C<>Hz5q@MdHZVMdf6$o7HC4;Wa|2x1{h6LfvZW`fLxfhCQvfUZY(TYxt!8%PBc N5S9UHJD}Sb7yv%a8SMZ7 literal 0 HcmV?d00001 diff --git a/examples/notes-python/dist/sql-runner.zip b/examples/notes-python/dist/sql-runner.zip new file mode 100644 index 0000000000000000000000000000000000000000..b684fdd65a10ad88487bf655a57212f82318e1ce GIT binary patch literal 796 zcmWIWW@Zs#-~d7f2E{HQ0S8<_Rz_l8N=|B#UO{Ef1jnq~4kB&et&TS9s5B}!akonC z@yNd1tC{;amgC^WV=JX9-DXP8o*}eB=7kz#{;n76!VT8>PT93>{fB#^Rg;X8PnUUb zndtWa&;RfH%Nd?7{&f2AV)1tO^>dhiTzM3DC}qO8hvy$iUy#)^IL>)4!$GpI(2sw| zg6bNkGn$7*^$PRWT@XljYnjZ?d%#`haTnW;B|OtDIOi}QkT*GSXW8FlhUYRk)*Ro# zHzhCgT4Id0w~1Ej6vB;Fql z`Vjp6@9odGPFjCIbE^9E%Diu-C$~I*_iwkVh3)6JPj~Rfys$kll_++#Xl`}99O+c#b-qWlHQmI__^yge$~ zBj7sckv}(+q^l3)MclZ0CiwcvbG!XS=K8JqF0_4fhxT?R-)Wy|Uv8*vmi-(y>C1m_PT_?!o?F*5 zGcf%BAK=Z-@#pAeXBH*~hDe}D0M4`{08BeYsfDGPMX9-|c_qbqB^4!F8tZ({>3f)8 z@;!CNTW8f1CKi~P*W(&~iUD { } } + // Добавляем метаданные запроса: путь, query params, метод. + // _path/_query/_method — зарезервированные поля, не конфликтуют с бизнес-полями. + // Позволяет роутить внутри одной функции: event._path === '/add' + const urlObj = new URL(req.url, 'http://localhost'); + const query = {}; + urlObj.searchParams.forEach((v, k) => { query[k] = v; }); + if (typeof event === 'object' && !Array.isArray(event)) { + event._path = urlObj.pathname; + event._query = query; + event._method = req.method; + } + try { const result = await userHandle(event); sendJSON(res, 200, result); diff --git a/runtimes/python3.11/server.py b/runtimes/python3.11/server.py index 7307d23..b2cdd42 100644 --- a/runtimes/python3.11/server.py +++ b/runtimes/python3.11/server.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Изменено: 2026-03-08 +# Изменено: 2026-03-09 # HTTP-обёртка для serverless функций на Python 3.11. # Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию. # Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle) @@ -10,6 +10,7 @@ import os import json import importlib.util from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs PORT = 8080 @@ -44,11 +45,25 @@ class FunctionHandler(BaseHTTPRequestHandler): format % args, )) + def _parse_request_meta(self, event): + # Добавляем _path, _query, _method в event — позволяет роутить внутри функции. + # Например: /fn/ns/notes/add?title=x → event._path='/add', event._query={'title':'x'} + # Зарезервированные поля, не конфликтуют с бизнес-полями. + parsed = urlparse(self.path) + query = {k: v[0] if len(v) == 1 else v + for k, v in parse_qs(parsed.query).items()} + if isinstance(event, dict): + event['_path'] = parsed.path + event['_query'] = query + event['_method'] = self.command + return event + def do_GET(self): if self.path == "/health": self._respond(200, {"status": "ok"}) else: - self._respond(200, _handle({})) + event = self._parse_request_meta({}) + self._respond(200, _handle(event)) def do_POST(self): length = int(self.headers.get("Content-Length", 0)) @@ -58,6 +73,7 @@ class FunctionHandler(BaseHTTPRequestHandler): except json.JSONDecodeError: # Если тело не JSON — передаём как строку, не ломаем вызов event = {"body": body.decode("utf-8", errors="replace")} + event = self._parse_request_meta(event) result = _handle(event) self._respond(200, result)