Skip to content

Instantly share code, notes, and snippets.

@masakielastic
Created February 25, 2026 01:33
Show Gist options
  • Select an option

  • Save masakielastic/12eb959da1ab0ded7f811df54605e681 to your computer and use it in GitHub Desktop.

Select an option

Save masakielastic/12eb959da1ab0ded7f811df54605e681 to your computer and use it in GitHub Desktop.
リクエストとレスポンスの扱い | PHP 拡張 ext-php-rs

リクエストとレスポンスの扱い | PHP 拡張 ext-php-rs

src/main.rs

[package]
name = "php_warp_server"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
ext-php-rs = "0.15"

warp = { version = "0.4", default-features = false, features = ["server"] }
tokio = { version = "1", features = ["full"] }

# warp::body::bytes() の型として Bytes を使うので残す
bytes = "1.11.1"

src/lib.rs

use std::{cell::RefCell, convert::Infallible, net::SocketAddr};

use bytes::Bytes;
use ext_php_rs::prelude::*;
use ext_php_rs::types::{ArrayKey, ZendCallable, ZendHashTable, Zval};
use warp::{Filter, Reply};
use ext_php_rs::boxed::ZBox;

thread_local! {
    static PHP_HANDLER: RefCell<Option<ZendCallable<'static>>> = const { RefCell::new(None) };
}

fn php_err<E: std::fmt::Display>(e: E) -> PhpException {
    PhpException::default(e.to_string())
}

/// 1) HTTP -> PHP request (array)
fn build_php_request(
    method: &warp::http::Method,
    full_path: &warp::path::FullPath,
    query: String,
    headers: warp::http::HeaderMap,
    body: Bytes,
) -> Result<ZBox<ZendHashTable>, PhpException> {
    let mut req = ZendHashTable::new();

    req.insert("method", method.as_str()).map_err(php_err)?;
    req.insert("path", full_path.as_str()).map_err(php_err)?;
    req.insert("query", query).map_err(php_err)?;

    // headers: 連想配列(小文字に正規化)
    let mut h = ZendHashTable::new();
    for (name, value) in headers.iter() {
        let k = name.as_str().to_ascii_lowercase();
        let v = value.to_str().unwrap_or("").to_string();
        h.insert(k, v).map_err(php_err)?;
    }
    req.insert("headers", h).map_err(php_err)?;

    // body: 教材として string 固定(UTF-8 lossy)
    let body_str = String::from_utf8_lossy(&body).to_string();
    req.insert("body", body_str).map_err(php_err)?;

    Ok(req)
}

/// 2) PHP handler invoke
fn invoke_php_handler(req: &ZBox<ZendHashTable>) -> Result<Zval, PhpException> {
    PHP_HANDLER.with(|h| {
        let h = h.borrow();
        let handler = h.as_ref().ok_or_else(|| php_err("handler is not set"))?;
        handler.try_call(vec![req]).map_err(php_err)
    })
}

/// PHP response 仕様:
/// - string を返した場合: body 扱い(status=200, headersなし)
/// - array を返した場合:
///   - status: int (省略時 200)
///   - body: string (省略時 "")
///   - headers: array (省略可)
///     - 連想配列: ["content-type" => "text/plain"]
///     - 数値キー配列: ["X-Foo: bar", "X-Baz: qux"]
fn parse_php_response(resp: &Zval) -> Result<warp::reply::Response, PhpException> {
    // string なら body として返す(互換維持)
    if resp.array().is_none() {
        let body = resp.str().unwrap_or("").to_string();
        return Ok(warp::reply::with_status(body, warp::http::StatusCode::OK).into_response());
    }

    let resp_ht = resp
        .array()
        .ok_or_else(|| php_err("handler returned invalid response"))?;

    // status
    let status_i = resp_ht.get("status").and_then(|z| z.long()).unwrap_or(200);
    let status_u16 = if (100..=999).contains(&status_i) {
        status_i as u16
    } else {
        500
    };
    let status =
        warp::http::StatusCode::from_u16(status_u16).unwrap_or(warp::http::StatusCode::OK);

    // body
    let body = resp_ht
        .get("body")
        .and_then(|z| z.str())
        .unwrap_or("")
        .to_string();

    let mut out = warp::reply::with_status(body, status).into_response();

    // headers
    if let Some(hz) = resp_ht.get("headers").and_then(|z| z.array()) {
        for (k, v) in hz.iter() {
            let (name, value) = match k {
                // 連想配列: key(string) => value(string)
                ArrayKey::String(s) => (s.clone(), v.str().unwrap_or("").to_string()),
                ArrayKey::Str(s) => (s.to_string(), v.str().unwrap_or("").to_string()),

                // 数値キー配列: "Name: value"
                ArrayKey::Long(_) => {
                    let line = v.str().unwrap_or("").to_string();
                    let Some((n, val)) = line.split_once(':') else { continue; };
                    (n.trim().to_string(), val.trim().to_string())
                }
            };

            if name.eq_ignore_ascii_case("content-length") {
                continue; // 事故防止
            }

            if let Ok(hname) = warp::http::header::HeaderName::from_bytes(name.as_bytes()) {
                if let Ok(hval) = warp::http::header::HeaderValue::from_str(&value) {
                    out.headers_mut().insert(hname, hval);
                }
            }
        }
    }

    Ok(out)
}

/// 3) warp route handler: build request -> call PHP -> parse response
async fn handle_request(
    method: warp::http::Method,
    full_path: warp::path::FullPath,
    query: String,
    headers: warp::http::HeaderMap,
    body: Bytes,
) -> Result<warp::reply::Response, Infallible> {
    let internal = |msg: String| {
        Ok::<warp::reply::Response, Infallible>(
            warp::reply::with_status(msg, warp::http::StatusCode::INTERNAL_SERVER_ERROR)
                .into_response(),
        )
    };

    let req = match build_php_request(&method, &full_path, query, headers, body) {
        Ok(r) => r,
        Err(e) => return internal(format!("build request failed: {e:?}")),
    };

    let resp_z = match invoke_php_handler(&req) {
        Ok(z) => z,
        Err(e) => return internal(format!("PHP handler error: {e:?}")),
    };

    match parse_php_response(&resp_z) {
        Ok(r) => Ok::<warp::reply::Response, Infallible>(r),
        Err(e) => internal(format!("parse response failed: {e:?}")),
    }
}

async fn serve_forever(host: String, port: u16) -> Result<(), PhpException> {
    let addr: SocketAddr = format!("{host}:{port}").parse().map_err(php_err)?;
    eprintln!("listening on http://{addr}");

    let route = warp::any()
        .and(warp::method())
        .and(warp::path::full())
        .and(warp::query::raw().or_else(|_| async {
            Ok::<(String,), Infallible>((String::new(),))
        }))
        .and(warp::header::headers_cloned())
        .and(warp::body::bytes())
        .and_then(handle_request);

    warp::serve(route).run(addr).await;
    Ok(())
}

#[php_function]
pub fn warp_http_serve(host: String, port: i64, handler: &mut Zval) -> PhpResult<()> {
    if !(0..=65535).contains(&port) {
        return Err(php_err("port must be in 0..65535"));
    }
    let port = port as u16;

    let owned_callable = ZendCallable::new_owned(handler.shallow_clone()).map_err(php_err)?;
    PHP_HANDLER.with(|h| *h.borrow_mut() = Some(owned_callable));

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(php_err)?;

    rt.block_on(async move { serve_forever(host, port).await })
        .map_err(|e| php_err(format!("{:?}", e)))?;

    Ok(())
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module.function(wrap_function!(warp_http_serve))
}

ビルドと実行

cargo build
php -d extension=target/debug/libphp_warp_server.so server.php
$f = function(array $req) {
    return [
        "status" => 200,
        "headers" => [
            "content-type" => "application/json; charset=utf-8",
        ],
        "body" => json_encode([
            "method"  => $req["method"],
            "path"    => $req["path"],
            "query"   => $req["query"],
            "headers" => $req["headers"],
        ], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
    ];
};

warp_http_serve("127.0.0.1", 8080, $f);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment