diff --git a/ohkami/src/x_worker.rs b/ohkami/src/x_worker.rs index 518033c0a..bac3ba419 100644 --- a/ohkami/src/x_worker.rs +++ b/ohkami/src/x_worker.rs @@ -2,6 +2,36 @@ pub use ::ohkami_macros::{worker, bindings, DurableObject}; +pub trait FromEnv: Sized { + fn from_env(env: &worker::Env) -> Result; + + #[doc(hidden)] + fn bindings_meta() -> &'static [(&'static str, &'static str)] { + &[] + } + #[doc(hidden)] + fn dummy_env() -> worker::Env { + use worker::wasm_bindgen::{JsCast, closure::Closure}; + use worker::js_sys::{Object, Reflect, Function}; + + let env = Object::new(); + for (binding_name, binding_type) in Self::bindings_meta() { + let binding = Object::new(); + if !binding_type.starts_with('$') { + let constructor = Function::unchecked_from_js(Closure::::new(|| {}).into_js_value()); + { + let attributes = Object::new(); + Reflect::set(&attributes, &"value".into(), &(*binding_type).into()).unwrap(); + Reflect::define_property(&constructor, &"name".into(), &attributes).unwrap(); + } + Reflect::set(&binding, &"constructor".into(), &constructor).unwrap(); + } + Reflect::set(&env, &(*binding_name).into(), &binding).unwrap(); + } + worker::Env::unchecked_from_js(env.unchecked_into()) + } +} + pub mod bindings { /// `Var` binding can also be accessed via associated const /// of the same name. diff --git a/ohkami_macros/src/worker.rs b/ohkami_macros/src/worker.rs index 790d819cc..662bba50e 100644 --- a/ohkami_macros/src/worker.rs +++ b/ohkami_macros/src/worker.rs @@ -8,21 +8,32 @@ use crate::util; use proc_macro2::{Span, TokenStream}; use quote::quote; -use syn::{spanned::Spanned, Error, Ident, ItemFn, ItemStruct, Fields, LitStr}; +use syn::{spanned::Spanned, Error, Ident, ItemFn, FnArg, ItemStruct, Fields, LitStr}; pub fn worker(args: TokenStream, ohkami_fn: TokenStream) -> Result { let worker_meta: meta::WorkerMeta = syn::parse2(args)?; + let ohkami_fn: ItemFn = syn::parse2(ohkami_fn)?; + if ohkami_fn.sig.inputs.len() >= 2 { + return Err(syn::Error::new( + ohkami_fn.span(), + "`#[worker]` doesn't support multiple arguments of the fn, \ + accepting 0 args or single arg which impls `FromEnv`." + )) + } let gen_ohkami = { - let name = &ohkami_fn.sig.ident; + let name = &ohkami_fn.sig.ident; + let env = ohkami_fn.sig.inputs.first().map(|_| quote! { + ::ohkami::FromEnv::from_env(&env).expect("`#[worker]` bindings arg has wrong `FromEnv` impl") + }); let awaiting = ohkami_fn.sig.asyncness.is_some().then_some(quote! { .await }); quote! { - #name()#awaiting + #name(#env)#awaiting } }; @@ -51,6 +62,16 @@ pub fn worker(args: TokenStream, ohkami_fn: TokenStream) -> Result &r.ty, + FnArg::Typed(p) => &p.ty, + }; + quote! { + let env = <#ty as ::ohkami::FromEnv>::dummy_env(); + } + }); + quote! { const _: () = { // `#[wasm_bindgen]` direcly references this modules in epxpaned code @@ -59,6 +80,7 @@ pub fn worker(args: TokenStream, ohkami_fn: TokenStream) -> Result Vec { + #dummy_env_def let ohkami: ::ohkami::Ohkami = #gen_ohkami; ohkami.__openapi_document_bytes__(::ohkami::openapi::OpenAPI { title: #title, @@ -272,6 +294,32 @@ pub fn bindings(env_name: TokenStream, bindings_struct: TokenStream) -> Result Result { + Self::new(env) + } + + fn bindings_meta() -> &'static [(&'static str, &'static str)] { + &[#(#bindings_meta),*] + } + } + } + }; + let impl_send_sync = if bindings.is_empty() || named_fields.is_some_and(|n| n.is_empty()) { @@ -288,6 +336,7 @@ pub fn bindings(env_name: TokenStream, bindings_struct: TokenStream) -> Result &'static str { + match self { + Self::Variable(_) => "String", + Self::AI => "Ai", + Self::D1 => "D1Database", + Self::KV => "$KV", + Self::R2 => "R2Bucket", + Self::Service => "Fetcher", + Self::Queue => "WorkerQueue", + Self::DurableObject => "DurableObjectNamespace", + } + } + pub fn tokens_ty(&self) -> TokenStream { match self { Self::Variable(_) => quote!(&'static str), diff --git a/samples/test.sh b/samples/test.sh index 5ca658313..778c9e77a 100755 --- a/samples/test.sh +++ b/samples/test.sh @@ -57,13 +57,19 @@ cd $SAMPLES/streaming && \ test $? -ne 0 && exit 156 || : cd $SAMPLES/worker-bindings && \ - cargo check + cargo check && \ + wasm-pack build --target nodejs --dev --no-opt --no-pack --no-typescript && \ + node dummy_env_test.js test $? -ne 0 && exit 157 || : cd $SAMPLES/worker-durable-websocket && \ cargo check test $? -ne 0 && exit 158 || : +cd $SAMPLES/worker-with-global-bindings && \ + npm run openapi +test $? -ne 0 && exit 159 || : + cd $SAMPLES/worker-with-openapi && \ cp wrangler.toml.sample wrangler.toml && \ (test -f openapi.json || echo '{}' >> openapi.json) && \ @@ -78,4 +84,4 @@ cd $SAMPLES/worker-with-openapi && \ diff openapi.json tmp.json \ ; (test -f tmp.json && rm tmp.json) \ || :) -test $? -ne 0 && exit 159 || : +test $? -ne 0 && exit 160 || : diff --git a/samples/worker-bindings/Cargo.toml b/samples/worker-bindings/Cargo.toml index 20dcdcfa9..45e8d5064 100644 --- a/samples/worker-bindings/Cargo.toml +++ b/samples/worker-bindings/Cargo.toml @@ -10,3 +10,4 @@ crate-type = ["cdylib", "rlib"] # set `default-features = false` to assure "DEBUG" feature be off even when DEBUGing `../ohkami` ohkami = { path = "../../ohkami", default-features = false, features = ["rt_worker"] } worker = { version = "0.5", features = ["queue", "d1"] } +console_error_panic_hook = "0.1" diff --git a/samples/worker-bindings/dummy_env_test.js b/samples/worker-bindings/dummy_env_test.js new file mode 100644 index 000000000..db6a2915c --- /dev/null +++ b/samples/worker-bindings/dummy_env_test.js @@ -0,0 +1,13 @@ +#! /usr/bin/env node + +import { join } from 'node:path'; +import { cwd, exit } from 'node:process'; + +const wasmpack_js = await import(join(cwd(), `pkg`, `worker_with_openapi.js`)); +if (!wasmpack_js) { + exit("wasmpack_js is not found") +} + +wasmpack_js.handle_dummy_env(); + +console.log("ok"); diff --git a/samples/worker-bindings/src/lib.rs b/samples/worker-bindings/src/lib.rs index 32d88c2d1..ebca59265 100644 --- a/samples/worker-bindings/src/lib.rs +++ b/samples/worker-bindings/src/lib.rs @@ -1,4 +1,5 @@ use ohkami::bindings; +use worker::wasm_bindgen; #[bindings] struct AutoBindings; @@ -71,3 +72,38 @@ fn __test_bindings_new__(env: &worker::Env) -> Result<(), worker::Error> { let _: ManualBindings = ManualBindings::new(env)?; Ok(()) } + +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn handle_dummy_env() { + use worker::wasm_bindgen::{JsCast, closure::Closure}; + use worker::js_sys::{Object, Reflect, Function}; + + console_error_panic_hook::set_once(); + + let dummy_db = { + let o = Object::new(); + { + let constructor = Function::unchecked_from_js(Closure::::new(|| {}).into_js_value()); + { + let attributes = Object::new(); + Reflect::set(&attributes, &"value".into(), &"D1Database".into()).unwrap(); + Reflect::define_property(&constructor, &"name".into(), &attributes).unwrap(); + } + Reflect::set(&o, &"constructor".into(), &constructor).unwrap(); + } + o + }; + + let dummy_env = { + let o = Object::new(); + { + Reflect::set(&o, &"DB".into(), &dummy_db).unwrap(); + Reflect::set(&o, &"MY_KVSTORE".into(), &Object::new()).unwrap(); + } + worker::Env::unchecked_from_js(o.unchecked_into()) + }; + + let _: ohkami::bindings::D1 = dummy_env.d1("DB").unwrap(); + + let _: ohkami::bindings::KV = dummy_env.kv("MY_KVSTORE").unwrap(); +} diff --git a/samples/worker-with-global-bindings/.gitignore b/samples/worker-with-global-bindings/.gitignore new file mode 100644 index 000000000..869df07da --- /dev/null +++ b/samples/worker-with-global-bindings/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock \ No newline at end of file diff --git a/samples/worker-with-global-bindings/Cargo.toml b/samples/worker-with-global-bindings/Cargo.toml new file mode 100644 index 000000000..13c2baa51 --- /dev/null +++ b/samples/worker-with-global-bindings/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "worker-with-global-bindings" +version = "0.1.0" +edition = "2024" + +[dependencies] +# set `default-features = false` to assure "DEBUG" feature be off even when DEBUGing `../ohkami` +ohkami = { path = "../../ohkami", default-features = false, features = ["rt_worker"] } +worker = { version = "0.5", features = ["d1"] } +thiserror = "1.0" +console_error_panic_hook = "0.1" + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = "s" + +[features] +openapi = ["ohkami/openapi"] + +# `--no-default-features` in release profile +default = ["openapi"] \ No newline at end of file diff --git a/samples/worker-with-global-bindings/migrations/0001_schema.sql b/samples/worker-with-global-bindings/migrations/0001_schema.sql new file mode 100644 index 000000000..7f381b8fc --- /dev/null +++ b/samples/worker-with-global-bindings/migrations/0001_schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + age INTEGER +); diff --git a/samples/worker-with-global-bindings/openapi.json b/samples/worker-with-global-bindings/openapi.json new file mode 100644 index 000000000..1bd1ac55b --- /dev/null +++ b/samples/worker-with-global-bindings/openapi.json @@ -0,0 +1,147 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "ohkami-with-global-bindings", + "version": "0.1.0" + }, + "servers": [ + { + "url": "http://localhost:8787", + "description": "local dev" + }, + { + "url": "https://worker-bindings-test.kanarus.workers.dev", + "description": "production" + } + ], + "paths": { + "/users": { + "get": { + "operationId": "list_users", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + } + } + } + }, + "post": { + "operationId": "create_user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + } + } + } + }, + "/users/{id}": { + "get": { + "operationId": "show_user", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "integer" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "age": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + } + } + } + } + } + } + } + } +} diff --git a/samples/worker-with-global-bindings/package.json b/samples/worker-with-global-bindings/package.json new file mode 100644 index 000000000..db8327192 --- /dev/null +++ b/samples/worker-with-global-bindings/package.json @@ -0,0 +1,13 @@ +{ + "name": "ohkami-with-global-bindings", + "version": "0.1.0", + "private": true, + "scripts": { + "deploy": "export OHKAMI_WORKER_DEV='' && wrangler deploy", + "dev": "export OHKAMI_WORKER_DEV=1 && wrangler dev", + "openapi": "node ../../scripts/workers_openapi.js" + }, + "devDependencies": { + "wrangler": "^3.50" + } +} diff --git a/samples/worker-with-global-bindings/src/lib.rs b/samples/worker-with-global-bindings/src/lib.rs new file mode 100644 index 000000000..8284e5180 --- /dev/null +++ b/samples/worker-with-global-bindings/src/lib.rs @@ -0,0 +1,188 @@ +use ohkami::prelude::*; +use ohkami::fang::Context; + +#[ohkami::bindings] +struct Bindings { + DB: ohkami::bindings::D1, + MY_KV: ohkami::bindings::KV, +} + +#[ohkami::worker] +async fn ohkami(Bindings { DB, MY_KV }: Bindings) -> Ohkami { + // just check to be able to retrieve even in openapi generation + let _ = MY_KV; + + Ohkami::new(( + Context::new(D1UserRepository::new(DB)), + "/users".By(routes::users_ohkami::()) + )) +} + +enum Error { + Worker(worker::Error), + Repository(String), + UserIdNotFound { id: u32 }, +} +impl From for Error { + fn from(e: worker::Error) -> Self { + Self::Worker(e) + } +} +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Self::Worker(e) => { + worker::console_error!("Worker's internal error: {e}"); + Response::InternalServerError() + } + Self::Repository(e) => { + worker::console_error!("Error from repository: {e}"); + Response::InternalServerError() + } + Self::UserIdNotFound { id } => { + worker::console_error!("Error: user not found by id: `{id}`"); + Response::NotFound() + } + } + } + + #[cfg(feature="openapi")] + fn openapi_responses() -> ohkami::openapi::Responses { + // there seems nothing needed to document + ohkami::openapi::Responses::new([]) + } +} + +#[derive(Clone)] +struct D1UserRepository { + d1: std::rc::Rc, +} +impl D1UserRepository { + fn new(d1: ohkami::bindings::D1) -> Self { + Self { d1: std::rc::Rc::new(d1) } + } +} +impl repository::UserRepository for D1UserRepository { + async fn get_all(&self) -> Result, Error> { + self.d1 + .prepare("SELECT id, name, age FROM users") + .all().await? + .results() + .map_err(Into::into) + } + + async fn get_by_id(&self, id: u32) -> Result, Error> { + self.d1 + .prepare("SELECT id, name, age FROM users WHERE id = ?") + .bind(&[id.into()])? + .first(None).await + .map_err(Into::into) + } + + async fn create_returning_id(&self, params: repository::CreateUserParams<'_>) -> Result { + let id = self.d1 + .prepare("INSERT INTO users (name, age) VALUES (?, ?) RETURNING id") + .bind(&[params.name.into(), params.age.into()])? + .first(Some("id")).await? + .ok_or_else(|| Error::Repository(format!("`id` not found in `RETURNING id` qruery result")))?; + Ok(id) + } +} + +mod repository { + #[derive(ohkami::serde::Deserialize)] + pub struct User { + pub id: u32, + pub name: String, + pub age: Option, + } + + pub struct CreateUserParams<'r> { + pub name: &'r str, + pub age: Option, + } + + pub trait UserRepository: 'static { + async fn get_all(&self) -> Result, crate::Error>; + + async fn get_by_id(&self, id: u32) -> Result, crate::Error>; + + async fn create_returning_id(&self, params: CreateUserParams<'_>) -> Result; + } +} + +mod routes { + use crate::repository::{self, UserRepository}; + use ohkami::{Ohkami, Route}; + use ohkami::format::JSON; + use ohkami::fang::Context; + use ohkami::typed::status; + use ohkami::serde::{Serialize, Deserialize}; + + pub fn users_ohkami() -> Ohkami { + Ohkami::new(( + "/" + .GET(list_users::) + .POST(create_user::), + "/:id" + .GET(show_user::), + )) + } + + #[derive(Serialize)] + #[cfg_attr(feature="openapi", derive(ohkami::openapi::Schema))] + pub struct User { + id: u32, + name: String, + age: Option, + } + + #[derive(Deserialize)] + #[cfg_attr(feature="openapi", derive(ohkami::openapi::Schema))] + pub struct CreateUserRequest<'req> { + name: &'req str, + age: Option, + } + + pub async fn list_users( + Context(r): Context<'_, U>, + ) -> Result>, crate::Error> { + let user_rows = r.get_all().await?; + + Ok(JSON(user_rows.into_iter().map(|r| User { + id: r.id, + name: r.name, + age: r.age, + }).collect())) + } + + pub async fn show_user( + id: u32, + Context(r): Context<'_, U>, + ) -> Result, crate::Error> { + let user_row = r.get_by_id(id).await? + .ok_or(crate::Error::UserIdNotFound { id })?; + + Ok(JSON(User { + id: user_row.id, + name: user_row.name, + age: user_row.age, + })) + } + + pub async fn create_user( + JSON(req): JSON>, + Context(r): Context<'_, U>, + ) -> Result>, crate::Error> { + let created_id = r.create_returning_id(repository::CreateUserParams { + name: &req.name, + age: req.age, + }).await?; + + Ok(status::Created(JSON(User { + id: created_id, + name: req.name.to_string(), + age: req.age, + }))) + } +} diff --git a/samples/worker-with-global-bindings/wrangler.toml b/samples/worker-with-global-bindings/wrangler.toml new file mode 100644 index 000000000..649119866 --- /dev/null +++ b/samples/worker-with-global-bindings/wrangler.toml @@ -0,0 +1,18 @@ +name = "worker-bindings-test" +main = "build/worker/shim.mjs" +compatibility_date = "2025-02-26" + +# `worker-build` and `wasm-pack` is required +# (run `cargo install wasm-pack worker-build` to install) + +[build] +command = "test $OHKAMI_WORKER_DEV && worker-build --dev || worker-build -- --no-default-features" + +[[d1_databases]] +binding = "DB" +database_name = "db" +database_id = "xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +[[kv_namespaces]] +binding = "MY_KV" +id = ""