Merge branch 'main' into feat/task-threads

# Conflicts:
#	types_test.go
This commit is contained in:
Alliballibaba
2025-10-26 20:24:42 +01:00
24 changed files with 537 additions and 190 deletions

1
.gitleaksignore Normal file
View File

@@ -0,0 +1 @@
/github/workspace/docs/mercure.md:jwt:65

View File

@@ -44,7 +44,7 @@ type FrankenPHPApp struct {
logger *slog.Logger
}
var iniError = errors.New("'php_ini' must be in the format: php_ini \"<key>\" \"<value>\"")
var iniError = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
// CaddyModule returns the Caddy module information.
func (f FrankenPHPApp) CaddyModule() caddy.ModuleInfo {
@@ -205,7 +205,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
v, err := time.ParseDuration(d.Val())
if err != nil {
return errors.New("max_wait_time must be a valid duration (example: 10s)")
return d.Err("max_wait_time must be a valid duration (example: 10s)")
}
f.MaxWaitTime = v
@@ -213,14 +213,14 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
parseIniLine := func(d *caddyfile.Dispenser) error {
key := d.Val()
if !d.NextArg() {
return iniError
return d.WrapErr(iniError)
}
if f.PhpIni == nil {
f.PhpIni = make(map[string]string)
}
f.PhpIni[key] = d.Val()
if d.NextArg() {
return iniError
return d.WrapErr(iniError)
}
return nil
@@ -237,7 +237,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
if !isBlock {
if !d.NextArg() {
return iniError
return d.WrapErr(iniError)
}
err := parseIniLine(d)
if err != nil {
@@ -261,12 +261,12 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
}
if strings.HasPrefix(wc.Name, "m#") {
return fmt.Errorf(`global worker names must not start with "m#": %q`, wc.Name)
return d.Errf(`global worker names must not start with "m#": %q`, wc.Name)
}
// check for duplicate workers
for _, existingWorker := range f.Workers {
if existingWorker.FileName == wc.FileName {
return fmt.Errorf("global workers must not have duplicate filenames: %q", wc.FileName)
return d.Errf("global workers must not have duplicate filenames: %q", wc.FileName)
}
}
@@ -279,7 +279,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
if f.MaxThreads > 0 && f.NumThreads > 0 && f.MaxThreads < f.NumThreads {
return errors.New(`"max_threads"" must be greater than or equal to "num_threads"`)
return d.Err(`"max_threads"" must be greater than or equal to "num_threads"`)
}
return nil
@@ -297,3 +297,8 @@ func parseGlobalOption(d *caddyfile.Dispenser, _ any) (any, error) {
Value: caddyconfig.JSON(app, nil),
}, nil
}
var (
_ caddy.App = (*FrankenPHPApp)(nil)
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
)

View File

@@ -7,9 +7,7 @@ import (
"fmt"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)
const (
@@ -35,12 +33,3 @@ func init() {
func wrongSubDirectiveError(module string, allowedDriectives string, wrongValue string) error {
return fmt.Errorf("unknown '%s' subdirective: '%s' (allowed directives are: %s)", module, wrongValue, allowedDriectives)
}
// Interface guards
var (
_ caddy.App = (*FrankenPHPApp)(nil)
_ caddy.Provisioner = (*FrankenPHPApp)(nil)
_ caddy.Provisioner = (*FrankenPHPModule)(nil)
_ caddyhttp.MiddlewareHandler = (*FrankenPHPModule)(nil)
_ caddyfile.Unmarshaler = (*FrankenPHPModule)(nil)
)

View File

@@ -17,7 +17,7 @@ func init() {
caddycmd.RegisterCommand(caddycmd.Command{
Name: "extension-init",
Usage: "go_extension.go [--verbose]",
Short: "(Experimental) Initializes a PHP extension from a Go file",
Short: "Initializes a PHP extension from a Go file (EXPERIMENTAL)",
Long: `
Initializes a PHP extension from a Go file. This command generates the necessary C files for the extension, including the header and source files, as well as the arginfo file.`,
CobraFunc: func(cmd *cobra.Command) {

View File

@@ -71,9 +71,8 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
}
for i, wc := range f.Workers {
// make the file path absolute from the public directory
// this can only be done if the root is definied inside php_server
// this can only be done if the root is defined inside php_server
if !filepath.IsAbs(wc.FileName) && f.Root != "" {
wc.FileName = filepath.Join(f.Root, wc.FileName)
}
@@ -602,7 +601,7 @@ func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f F
// forward matching routes to the PHP handler
routes = append(routes, caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{
caddy.ModuleMap{"path": h.JSON(allWorkerMatches)},
{"path": h.JSON(allWorkerMatches)},
},
HandlersRaw: []json.RawMessage{
caddyconfig.JSONModuleObject(f, "handler", "php", nil),
@@ -611,3 +610,10 @@ func prependWorkerRoutes(routes caddyhttp.RouteList, h httpcaddyfile.Helper, f F
return routes
}
// Interface guards
var (
_ caddy.Provisioner = (*FrankenPHPModule)(nil)
_ caddyhttp.MiddlewareHandler = (*FrankenPHPModule)(nil)
_ caddyfile.Unmarshaler = (*FrankenPHPModule)(nil)
)

View File

@@ -59,7 +59,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {
}
if d.NextArg() {
return wc, errors.New(`FrankenPHP: too many "worker" arguments: ` + d.Val())
return wc, d.Errf(`FrankenPHP: too many "worker" arguments: %s`, d.Val())
}
for d.NextBlock(1) {

View File

@@ -1,17 +1,38 @@
# Configuration
FrankenPHP, Caddy as well as the Mercure and Vulcain modules can be configured using [the formats supported by Caddy](https://caddyserver.com/docs/getting-started#your-first-config).
FrankenPHP, Caddy as well as the [Mercure](mercure.md) and [Vulcain](https://vulcain.rocks) modules can be configured using [the formats supported by Caddy](https://caddyserver.com/docs/getting-started#your-first-config).
In [the Docker images](docker.md), the `Caddyfile` is located at `/etc/frankenphp/Caddyfile`.
The static binary will also look for the `Caddyfile` in the directory where the `frankenphp run` command is executed.
The most common format is the `Caddyfile`, which is a simple, human-readable text format.
By default, FrankenPHP will look for a `Caddyfile` in the current directory.
You can specify a custom path with the `-c` or `--config` option.
A minimal `Caddyfile` to serve a PHP application is shown below:
```caddyfile
# The hostname to respond to
localhost
# Optionaly, the directory to serve files from, otherwise defaults to the current directory
#root public/
php_server
```
A more advanced `Caddyfile` enabling more features and providing convenient environment variables is provided [in the FrankenPHP repository](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile),
and with Docker images.
PHP itself can be configured [using a `php.ini` file](https://www.php.net/manual/en/configuration.file.php).
Depending on your installation method, the PHP interpreter will look for configuration files in locations described below.
Depending on your installation method, FrankenPHP and the PHP interpreter will look for configuration files in locations described below.
## Docker
FrankenPHP:
- `/etc/frankenphp/Caddyfile`: the main configuration file
- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically
PHP:
- `php.ini`: `/usr/local/etc/php/php.ini` (no `php.ini` is provided by default)
- additional configuration files: `/usr/local/etc/php/conf.d/*.ini`
- PHP extensions: `/usr/local/lib/php/extensions/no-debug-zts-<YYYYMMDD>/`
@@ -29,12 +50,25 @@ RUN cp $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini
## RPM and Debian packages
FrankenPHP:
- `/etc/frankenphp/Caddyfile`: the main configuration file
- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically
PHP:
- `php.ini`: `/etc/frankenphp/php.ini` (a `php.ini` file with production presets is provided by default)
- additional configuration files: `/etc/frankenphp/php.d/*.ini`
- PHP extensions: `/usr/lib/frankenphp/modules/`
## Static binary
FrankenPHP:
- In the current working directory: `Caddyfile`
PHP:
- `php.ini`: The directory in which `frankenphp run` or `frankenphp php-server` is executed, then `/etc/frankenphp/php.ini`
- additional configuration files: `/etc/frankenphp/php.d/*.ini`
- PHP extensions: cannot be loaded, bundle them in the binary itself
@@ -229,34 +263,6 @@ and otherwise forward the request to the worker matching the path pattern.
}
```
### Full Duplex (HTTP/1)
When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body
has been read. (for example: WebSocket, Server-Sent Events, etc.)
This is an opt-in configuration that needs to be added to the global options in the `Caddyfile`:
```caddyfile
{
servers {
enable_full_duplex
}
}
```
> [!CAUTION]
>
> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock.
> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config:
```sh
CADDY_GLOBAL_OPTIONS="servers {
enable_full_duplex
}"
```
You can find more information about this setting in the [Caddy documentation](https://caddyserver.com/docs/caddyfile/options#enable-full-duplex).
## Environment Variables
The following environment variables can be used to inject Caddy directives in the `Caddyfile` without modifying it:
@@ -293,6 +299,43 @@ You can also change the PHP configuration using the `php_ini` directive in the `
}
```
### Disabling HTTPS
By default, FrankenPHP will automatically enable HTTPS using for all the hostnames, including `localhost`.
If you want to disable HTTPS (for example in a development environment), you can set the `SERVER_NAME` environment variable to `http://` or `:80`:
Alternatively, you can use all other methods described in the [Caddy documentation](https://caddyserver.com/docs/automatic-https#activation).
If you want to use HTTPS with the `127.0.0.1` IP address instead of the `localhost` hostname, please read the [known issues](known-issues.md#using-https127001-with-docker) section.
### Full Duplex (HTTP/1)
When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body
has been read. (for example: [Mercure](mercure.md), WebSocket, Server-Sent Events, etc.)
This is an opt-in configuration that needs to be added to the global options in the `Caddyfile`:
```caddyfile
{
servers {
enable_full_duplex
}
}
```
> [!CAUTION]
>
> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock.
> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config:
```sh
CADDY_GLOBAL_OPTIONS="servers {
enable_full_duplex
}"
```
You can find more information about this setting in the [Caddy documentation](https://caddyserver.com/docs/caddyfile/options#enable-full-duplex).
## Enable the Debug Mode
When using the Docker image, set the `CADDY_GLOBAL_OPTIONS` environment variable to `debug` to enable the debug mode:

View File

@@ -1,6 +1,8 @@
# Building Custom Docker Image
[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/). Debian and Alpine Linux variants are provided for popular architectures. Debian variants are recommended.
[FrankenPHP Docker images](https://hub.docker.com/r/dunglas/frankenphp) are based on [official PHP images](https://hub.docker.com/_/php/).
Debian and Alpine Linux variants are provided for popular architectures.
Debian variants are recommended.
Variants for PHP 8.2, 8.3 and 8.4 are provided.
@@ -28,6 +30,11 @@ docker build -t my-php-app .
docker run -it --rm --name my-running-app my-php-app
```
## How to Tweak the Configuration
For convenience, [a default `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile) containing
useful environment variables is provided in the image.
## How to Install More PHP Extensions
The [`docker-php-extension-installer`](https://github.com/mlocati/docker-php-extension-installer) script is provided in the base image.

View File

@@ -33,7 +33,7 @@ As covered in the manual implementation section below as well, you need to [get
The first step to writing a PHP extension in Go is to create a new Go module. You can use the following command for this:
```console
go mod init example.com/example
go mod init example.com/example
```
The second step is to [get the PHP sources](https://www.php.net/downloads.php) for the next steps. Once you have them, decompress them into the directory of your choice, not inside your Go module:
@@ -49,10 +49,11 @@ Everything is now setup to write your native function in Go. Create a new file n
```go
package example
// #include <Zend/zend_types.h>
// #include <Zend/zend_types.h>
import "C"
import (
"strings"
"unsafe"
"github.com/dunglas/frankenphp"
)
@@ -126,14 +127,17 @@ package example
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:function process_data_ordered(array $input): array
func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
// Convert PHP associative array to Go while keeping the order
associativeArray := frankenphp.GoAssociativeArray(unsafe.Pointer(arr))
associativeArray, err := frankenphp.GoAssociativeArray[any](unsafe.Pointer(arr))
if err != nil {
// handle error
}
// loop over the entries in order
for _, key := range associativeArray.Order {
@@ -143,8 +147,8 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
// return an ordered array
// if 'Order' is not empty, only the key-value pairs in 'Order' will be respected
return frankenphp.PHPAssociativeArray(frankenphp.AssociativeArray{
Map: map[string]any{
return frankenphp.PHPAssociativeArray[string](frankenphp.AssociativeArray[string]{
Map: map[string]string{
"key1": "value1",
"key2": "value2",
},
@@ -156,7 +160,10 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
// Convert PHP associative array to a Go map without keeping the order
// ignoring the order will be more performant
goMap := frankenphp.GoMap(unsafe.Pointer(arr))
goMap, err := frankenphp.GoMap[any](unsafe.Pointer(arr))
if err != nil {
// handle error
}
// loop over the entries in no specific order
for key, value := range goMap {
@@ -164,7 +171,7 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
}
// return an unordered array
return frankenphp.PHPMap(map[string]any{
return frankenphp.PHPMap(map[string]string {
"key1": "value1",
"key2": "value2",
})
@@ -173,7 +180,10 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
// export_php:function process_data_packed(array $input): array
func process_data_packed(arr *C.zval) unsafe.Pointer {
// Convert PHP packed array to Go
goSlice := frankenphp.GoPackedArray(unsafe.Pointer(arr), false)
goSlice, err := frankenphp.GoPackedArray(unsafe.Pointer(arr), false)
if err != nil {
// handle error
}
// loop over the slice in order
for index, value := range goSlice {
@@ -181,7 +191,7 @@ func process_data_packed(arr *C.zval) unsafe.Pointer {
}
// return a packed array
return frankenphp.PHPackedArray([]any{"value1", "value2", "value3"})
return frankenphp.PHPPackedArray([]string{"value1", "value2", "value3"})
}
```
@@ -790,7 +800,7 @@ import "C"
import (
"unsafe"
"strings"
"github.com/dunglas/frankenphp"
)

View File

@@ -1,4 +1,4 @@
# Configuration
# Configuration
FrankenPHP, Caddy ainsi que les modules Mercure et Vulcain peuvent être configurés en utilisant [les formats pris en charge par Caddy](https://caddyserver.com/docs/getting-started#your-first-config).

View File

@@ -75,7 +75,7 @@ Il y a deux choses importantes à noter ici :
- Une directive `//export_php:function` définit la signature de la fonction en PHP. C'est ainsi que le générateur sait comment générer la fonction PHP avec les bons paramètres et le bon type de retour ;
- La fonction doit retourner un `unsafe.Pointer`. FrankenPHP fournit une API pour vous aider avec le jonglage de types entre C et Go.
Alors que le premier point parle de lui-même, le second peut être plus difficile à appréhender. Plongeons plus profondément dans la jonglage de types dans la section suivante.
Alors que le premier point parle de lui-même, le second peut être plus difficile à appréhender. Plongeons plus profondément dans le jonglage de types dans la section suivante.
### Jonglage de Types

View File

@@ -13,9 +13,9 @@ The following extensions are known not to be compatible with FrankenPHP:
The following extensions have known bugs and unexpected behaviors when used with FrankenPHP:
| Name | Problem |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [ext-openssl](https://www.php.net/manual/en/book.openssl.php) | When using a static build of FrankenPHP (built with the musl libc), the OpenSSL extension may crash under heavy loads. A workaround is to use a dynamically linked build (like the one used in Docker images). This bug is [being tracked by PHP](https://github.com/php/php-src/issues/13648). |
| Name | Problem |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [ext-openssl](https://www.php.net/manual/en/book.openssl.php) | When using musl libc, the OpenSSL extension may crash under heavy loads. The problem doesn't occur when using the more popular GNU libc. This bug is [being tracked by PHP](https://github.com/php/php-src/issues/13648). |
## get_browser
@@ -23,7 +23,11 @@ The [get_browser()](https://www.php.net/manual/en/function.get-browser.php) func
## Standalone Binary and Alpine-based Docker Images
The standalone binary and Alpine-based Docker images (`dunglas/frankenphp:*-alpine`) use [musl libc](https://musl.libc.org/) instead of [glibc and friends](https://www.etalabs.net/compare_libcs.html), to keep a smaller binary size. This may lead to some compatibility issues. In particular, the glob flag `GLOB_BRACE` is [not available](https://www.php.net/manual/en/function.glob.php)
The fully binary and Alpine-based Docker images (`dunglas/frankenphp:*-alpine`) use [musl libc](https://musl.libc.org/) instead of [glibc and friends](https://www.etalabs.net/compare_libcs.html), to keep a smaller binary size.
This may lead to some compatibility issues.
In particular, the glob flag `GLOB_BRACE` is [not available](https://www.php.net/manual/en/function.glob.php)
Prefer using the GNU variant of the static binary and Debian-based Docker images if you encounter issues.
## Using `https://127.0.0.1` with Docker

View File

@@ -76,6 +76,8 @@ The `octane:frankenphp` command can take the following options:
> [!TIP]
> To get structured JSON logs (useful when using log analytics solutions), explicitly the pass `--log-level` option.
See also [how to use Mercure with Octane](#mercure-support).
Learn more about [Laravel Octane in its official documentation](https://laravel.com/docs/octane).
## Laravel Apps As Standalone Binaries
@@ -166,6 +168,34 @@ This is not suitable for embedded applications, as each new version will be extr
Set the `LARAVEL_STORAGE_PATH` environment variable (for example, in your `.env` file) or call the `Illuminate\Foundation\Application::useStoragePath()` method to use a directory outside the temporary directory.
### Mercure Support
[Mercure](https://mercure.rocks) is a great way to add real-time capabilities to your Laravel apps.
FrankenPHP includes [Mercure support out of the box](mercure.md).
If you are not using [Octane](#laravel-octane), see [the Mercure documentation entry](mercure.md).
If you are using Octane, you can use enable Mercure support by adding the following lines to your `config/octane.php` file:
```php
// ...
return [
// ...
'mercure' => [
'anonymous' => true,
'publisher_jwt' => '!ChangeThisMercureHubJWTSecretKey!',
'subscriber_jwt' => '!ChangeThisMercureHubJWTSecretKey!',
],
];
```
You can use [all directives supported by Mercure](https://mercure.rocks/docs/hub/config#directives) in this array.
To publish and subscribe to updates, we recommend using the [Laravel Mercure Broadcaster](https://github.com/mvanduijker/laravel-mercure-broadcaster) library.
Alternatively, see [the Mercure documentation](mercure.md) to do it in pure PHP and JavaScript.
### Running Octane With Standalone Binaries
It's even possible to package Laravel Octane apps as standalone binaries!

View File

@@ -3,13 +3,124 @@
FrankenPHP comes with a built-in [Mercure](https://mercure.rocks) hub!
Mercure allows you to push real-time events to all the connected devices: they will receive a JavaScript event instantly.
No JS library or SDK is required!
It's a convenient alternative to WebSockets that is simple to use and is natively supported by all modern web browsers!
![Mercure](mercure-hub.png)
To enable the Mercure hub, update the `Caddyfile` as described [on Mercure's site](https://mercure.rocks/docs/hub/config).
## Enabling Mercure
The path of the Mercure hub is `/.well-known/mercure`.
When running FrankenPHP inside Docker, the full send URL would look like `http://php/.well-known/mercure` (with `php` being the container's name running FrankenPHP).
Mercure support is disabled by default.
Here is a minimal example of a `Caddyfile` enabling both FrankenPHP and the Mercure hub:
To push Mercure updates from your code, we recommend the [Symfony Mercure Component](https://symfony.com/components/Mercure) (you don't need the Symfony full-stack framework to use it).
```caddyfile
# The hostname to respond to
localhost
mercure {
# The secret key used to sign the JWT tokens for publishers
publisher_jwt !ChangeThisMercureHubJWTSecretKey!
# Allows anonymous subscribers (without JWT)
anonymous
}
root public/
php_server
```
> [!TIP]
>
> The [sample `Caddyfile`](https://github.com/php/frankenphp/blob/main/caddy/frankenphp/Caddyfile)
> provided by [the Docker images](docker.md) already includes a commented Mercure configuration
> with convenient environment variables to configure it.
>
> Uncomment the Mercure section in `/etc/frankenphp/Caddyfile` to enable it.
## Subscribing to Updates
By default, the Mercure hub is available on the `/.well-known/mercure` path of your FrankenPHP server.
To subscribe to updates, use the native [`EventSource`](https://developer.mozilla.org/docs/Web/API/EventSource) JavaScript class:
```html
<!-- public/index.html -->
<!doctype html>
<title>Mercure Example</title>
<script>
const eventSource = new EventSource("/.well-known/mercure?topic=my-topic");
eventSource.onmessage = function (event) {
console.log("New message:", event.data);
};
</script>
```
## Publishing Updates
### Using `file_put_contents()`
To dispatch an update to connected subscribers, send an authenticated POST request to the Mercure hub with the `topic` and `data` parameters:
```php
<?php
// public/publish.php
const JWT = 'eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.PXwpfIGng6KObfZlcOXvcnWCJOWTFLtswGI5DZuWSK4';
$updateID = file_put_contents('https://localhost/.well-known/mercure', context: stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-type: application/x-www-form-urlencoded\r\nAuthorization: Bearer " . JWT,
'content' => http_build_query([
'topic' => 'my-topic',
'data' => json_encode(['key' => 'value']),
]),
]]));
// Write to FrankenPHP's logs
error_log("update $updateID published", 4);
```
The key passed as parameter of the `mercure.publisher_jwt` option in the `Caddyfile` must used to sign the JWT token used in the `Authorization` header.
The JWT must include a `mercure` claim with a `publish` permission for the topics you want to publish to.
See [the Mercure documentation](https://mercure.rocks/spec#publishers) about authorization.
To generate your own tokens, you can use [this jwt.io link](https://www.jwt.io/#token=eyJhbGciOiJIUzI1NiJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.PXwpfIGng6KObfZlcOXvcnWCJOWTFLtswGI5DZuWSK4),
but for production apps, it's recommended to use short-lived tokens generated aerodynamically using with a trusted [JWT library](https://www.jwt.io/libraries?programming_language=php).
### Using Symfony Mercure
Alternatively, you can use the [Symfony Mercure Component](https://symfony.com/components/Mercure), a standalone PHP library.
This library handled the JWT generation, update publishing as well as cookie-based authorization for subscribers.
First, install the library using Composer:
```console
composer require symfony/mercure lcobucci/jwt
```
Then, you can use it like this:
```php
<?php
// public/publish.php
require __DIR__ . '/../vendor/autoload.php';
const JWT_SECRET = '!ChangeThisMercureHubJWTSecretKey!'; // Must be the same as mercure.publisher_jwt in Caddyfile
// Set up the JWT token provider
$jwFactory = new \Symfony\Component\Mercure\Jwt\LcobucciFactory(JWT_SECRET);
$provider = new \Symfony\Component\Mercure\Jwt\FactoryTokenProvider($jwFactory, publish: ['*']);
$hub = new \Symfony\Component\Mercure\Hub('https://localhost/.well-known/mercure', $provider);
// Serialize the update, and dispatch it to the hub, that will broadcast it to the clients
$updateID = $hub->publish(new \Symfony\Component\Mercure\Update('my-topic', json_encode(['key' => 'value'])));
// Write to FrankenPHP's logs
error_log("update $updateID published", 4);
```
Mercure is also natively supported by:
- [Laravel](laravel.md#mercure-support)
- [Symfony](https://symfony.com/doc/current/mercure.html)
- [API Platform](https://api-platform.com/docs/core/mercure/)

View File

@@ -498,9 +498,18 @@ func go_write_headers(threadIndex C.uintptr_t, status C.int, headers *C.zend_lli
current = current.next
}
fc.responseWriter.WriteHeader(int(status))
goStatus := int(status)
if status >= 100 && status < 200 {
// go panics on invalid status code
// https://github.com/golang/go/blob/9b8742f2e79438b9442afa4c0a0139d3937ea33f/src/net/http/server.go#L1162
if goStatus < 100 || goStatus > 999 {
logger.Warn(fmt.Sprintf("Invalid response status code %v", goStatus))
goStatus = 500
}
fc.responseWriter.WriteHeader(goStatus)
if goStatus >= 100 && goStatus < 200 {
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
h := fc.responseWriter.Header()
for k := range h {

View File

@@ -313,7 +313,7 @@ func TestCFileSpecialCharacters(t *testing.T) {
content, err := cGen.buildContent()
require.NoError(t, err)
expectedInclude := "#include \"" + tt.expected + ".h\""
expectedInclude := `#include "` + tt.expected + `.h"`
assert.Contains(t, content, expectedInclude, "Content should contain include: %s", expectedInclude)
})
}
@@ -424,8 +424,8 @@ func TestCFileConstants(t *testing.T) {
},
},
contains: []string{
"REGISTER_LONG_CONSTANT(\"GLOBAL_INT\", 42, CONST_CS | CONST_PERSISTENT);",
"REGISTER_STRING_CONSTANT(\"GLOBAL_STRING\", \"test\", CONST_CS | CONST_PERSISTENT);",
`REGISTER_LONG_CONSTANT("GLOBAL_INT", 42, CONST_CS | CONST_PERSISTENT);`,
`REGISTER_STRING_CONSTANT("GLOBAL_STRING", "test", CONST_CS | CONST_PERSISTENT);`,
},
},
}

View File

@@ -99,7 +99,7 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e
func determineConstantType(value string) phpType {
value = strings.TrimSpace(value)
if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) ||
(strings.HasPrefix(value, "`") && strings.HasSuffix(value, "`")) {
return phpString
}

View File

@@ -250,7 +250,7 @@ func TestConstantParserTypeDetection(t *testing.T) {
value string
expectedType phpType
}{
{"string with double quotes", "\"hello world\"", phpString},
{"string with double quotes", `"hello world"`, phpString},
{"string with backticks", "`hello world`", phpString},
{"boolean true", "true", phpBool},
{"boolean false", "false", phpBool},
@@ -443,13 +443,13 @@ func TestConstantParserDeclRegex(t *testing.T) {
name string
value string
}{
{"const MyConst = \"value\"", true, "MyConst", "\"value\""},
{`const MyConst = "value"`, true, "MyConst", `"value"`},
{"const IntConst = 42", true, "IntConst", "42"},
{"const BoolConst = true", true, "BoolConst", "true"},
{"const IotaConst = iota", true, "IotaConst", "iota"},
{"const ComplexValue = someFunction()", true, "ComplexValue", "someFunction()"},
{"const SpacedName = \"with spaces\"", true, "SpacedName", "\"with spaces\""},
{"var notAConst = \"value\"", false, "", ""},
{`const SpacedName = "with spaces"`, true, "SpacedName", `"with spaces"`},
{`var notAConst = "value"`, false, "", ""},
{"const", false, "", ""},
{"const =", false, "", ""},
}
@@ -518,10 +518,10 @@ func TestPHPConstantCValue(t *testing.T) {
name: "string constant",
constant: phpConstant{
Name: "StringConst",
Value: "\"hello\"",
Value: `"hello"`,
PhpType: phpString,
},
expected: "\"hello\"", // strings should remain unchanged
expected: `"hello"`, // strings should remain unchanged
},
{
name: "boolean constant",

View File

@@ -134,7 +134,7 @@ func TestStubGenerator_BuildContent(t *testing.T) {
contains: []string{
"<?php",
"/** @generate-class-entries */",
"const GLOBAL_CONST = \"test\";",
`const GLOBAL_CONST = "test";`,
},
},
{

View File

@@ -26,7 +26,7 @@ func NamespacedName(namespace, name string) string {
if namespace == "" {
return name
}
namespacePart := strings.ReplaceAll(namespace, "\\", "_")
namespacePart := strings.ReplaceAll(namespace, `\`, "_")
return namespacePart + "_" + name
}

View File

@@ -62,12 +62,6 @@ RUN if [ "$(uname -m)" = "aarch64" ]; then \
echo "source scl_source enable devtoolset-10" >> /etc/bashrc && \
source /etc/bashrc
# install newer cmake to build some newer libs
RUN curl -o cmake.tar.gz -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.4/cmake-3.31.4-linux-$(uname -m).tar.gz && \
mkdir /cmake && \
tar -xzf cmake.tar.gz -C /cmake --strip-components 1 && \
rm cmake.tar.gz
# install build essentials
RUN yum install -y \
perl \
@@ -95,6 +89,15 @@ RUN yum install -y \
ln -sf /usr/local/bin/make /usr/bin/make && \
cd .. && \
rm -Rf make* && \
curl -o cmake.tar.gz -fsSL https://github.com/Kitware/CMake/releases/download/v4.1.2/cmake-4.1.2-linux-$(uname -m).tar.gz && \
mkdir /cmake && \
tar -xzf cmake.tar.gz -C /cmake --strip-components 1 && \
rm cmake.tar.gz && \
curl -fsSL -o patchelf.tar.gz https://github.com/NixOS/patchelf/releases/download/0.18.0/patchelf-0.18.0-$(uname -m).tar.gz && \
mkdir -p /patchelf && \
tar -xzf patchelf.tar.gz -C /patchelf --strip-components=1 && \
cp /patchelf/bin/patchelf /usr/bin/ && \
rm patchelf.tar.gz && \
if [ "$(uname -m)" = "aarch64" ]; then \
GO_ARCH="arm64" ; \
else \

View File

@@ -4,6 +4,7 @@ package frankenphp
import "C"
import (
"context"
"fmt"
"log/slog"
"path/filepath"
"time"
@@ -246,7 +247,12 @@ func go_frankenphp_finish_worker_request(threadIndex C.uintptr_t, retval *C.zval
thread := phpThreads[threadIndex]
fc := thread.getRequestContext()
if retval != nil {
fc.handlerReturn = GoValue(unsafe.Pointer(retval))
r, err := GoValue[any](unsafe.Pointer(retval))
if err != nil {
logger.Error(fmt.Sprintf("cannot convert return value: %s", err))
}
fc.handlerReturn = r
}
fc.closeContext()

260
types.go
View File

@@ -5,11 +5,17 @@ package frankenphp
*/
import "C"
import (
"errors"
"fmt"
"reflect"
"strconv"
"unsafe"
)
type toZval interface {
toZval() *C.zval
}
// EXPERIMENTAL: GoString copies a zend_string to a Go string.
func GoString(s unsafe.Pointer) string {
if s == nil {
@@ -39,37 +45,44 @@ func PHPString(s string, persistent bool) unsafe.Pointer {
}
// AssociativeArray represents a PHP array with ordered key-value pairs
type AssociativeArray struct {
Map map[string]any
type AssociativeArray[T any] struct {
Map map[string]T
Order []string
}
func (a AssociativeArray[T]) toZval() *C.zval {
return (*C.zval)(PHPAssociativeArray[T](a))
}
// EXPERIMENTAL: GoAssociativeArray converts a zend_array to a Go AssociativeArray
func GoAssociativeArray(arr unsafe.Pointer) AssociativeArray {
entries, order := goArray(arr, true)
return AssociativeArray{entries, order}
func GoAssociativeArray[T any](arr unsafe.Pointer) (AssociativeArray[T], error) {
entries, order, err := goArray[T](arr, true)
return AssociativeArray[T]{entries, order}, err
}
// EXPERIMENTAL: GoMap converts a zval having a zend_array value to an unordered Go map
func GoMap(arr unsafe.Pointer) map[string]any {
entries, _ := goArray(arr, false)
return entries
func GoMap[T any](arr unsafe.Pointer) (map[string]T, error) {
entries, _, err := goArray[T](arr, false)
return entries, err
}
func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) {
func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, error) {
if arr == nil {
panic("received a nil pointer on array conversion")
return nil, nil, errors.New("received a nil pointer on array conversion")
}
zval := (*C.zval)(arr)
hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY))
if hashTable == nil {
panic("received a *zval that wasn't a HashTable on array conversion")
v, err := extractZvalValue(zval, C.IS_ARRAY)
if err != nil {
return nil, nil, fmt.Errorf("received a *zval that wasn't a HashTable on array conversion: %w", err)
}
hashTable := (*C.HashTable)(v)
nNumUsed := hashTable.nNumUsed
entries := make(map[string]any, nNumUsed)
entries := make(map[string]T, nNumUsed)
var order []string
if ordered {
order = make([]string, 0, nNumUsed)
@@ -83,27 +96,42 @@ func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) {
v := C.get_ht_packed_data(hashTable, i)
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
strIndex := strconv.Itoa(int(i))
entries[strIndex] = goValue(v)
e, err := goValue[T](v)
if err != nil {
return nil, nil, err
}
entries[strIndex] = e
if ordered {
order = append(order, strIndex)
}
}
}
return entries, order
return entries, order, nil
}
var zeroVal T
for i := C.uint32_t(0); i < nNumUsed; i++ {
bucket := C.get_ht_bucket_data(hashTable, i)
if bucket == nil || C.zval_get_type(&bucket.val) == C.IS_UNDEF {
continue
}
v := goValue(&bucket.val)
v, err := goValue[any](&bucket.val)
if err != nil {
return nil, nil, err
}
if bucket.key != nil {
keyStr := GoString(unsafe.Pointer(bucket.key))
entries[keyStr] = v
if v == nil {
entries[keyStr] = zeroVal
} else {
entries[keyStr] = v.(T)
}
if ordered {
order = append(order, keyStr)
}
@@ -113,64 +141,75 @@ func goArray(arr unsafe.Pointer, ordered bool) (map[string]any, []string) {
// as fallback convert the bucket index to a string key
strIndex := strconv.Itoa(int(bucket.h))
entries[strIndex] = v
entries[strIndex] = v.(T)
if ordered {
order = append(order, strIndex)
}
}
return entries, order
return entries, order, nil
}
// EXPERIMENTAL: GoPackedArray converts a zval with a zend_array value to a Go slice
func GoPackedArray(arr unsafe.Pointer) []any {
func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
if arr == nil {
panic("GoPackedArray received a nil pointer")
return nil, errors.New("GoPackedArray received a nil value")
}
zval := (*C.zval)(arr)
hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY))
if hashTable == nil {
panic("GoPackedArray received *zval that wasn't a HashTable")
v, err := extractZvalValue(zval, C.IS_ARRAY)
if err != nil {
return nil, fmt.Errorf("GoPackedArray received *zval that wasn't a HashTable: %w", err)
}
hashTable := (*C.HashTable)(v)
nNumUsed := hashTable.nNumUsed
result := make([]any, 0, nNumUsed)
result := make([]T, 0, nNumUsed)
if htIsPacked(hashTable) {
for i := C.uint32_t(0); i < nNumUsed; i++ {
v := C.get_ht_packed_data(hashTable, i)
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
result = append(result, goValue(v))
v, err := goValue[T](v)
if err != nil {
return nil, err
}
result = append(result, v)
}
}
return result
return result, nil
}
// fallback if ht isn't packed - equivalent to array_values()
for i := C.uint32_t(0); i < nNumUsed; i++ {
bucket := C.get_ht_bucket_data(hashTable, i)
if bucket != nil && C.zval_get_type(&bucket.val) != C.IS_UNDEF {
result = append(result, goValue(&bucket.val))
v, err := goValue[T](&bucket.val)
if err != nil {
return nil, err
}
result = append(result, v)
}
}
return result
return result, nil
}
// EXPERIMENTAL: PHPMap converts an unordered Go map to a PHP zend_array
func PHPMap(arr map[string]any) unsafe.Pointer {
return phpArray(arr, nil)
func PHPMap[T any](arr map[string]T) unsafe.Pointer {
return phpArray[T](arr, nil)
}
// EXPERIMENTAL: PHPAssociativeArray converts a Go AssociativeArray to a PHP zval with a zend_array value
func PHPAssociativeArray(arr AssociativeArray) unsafe.Pointer {
return phpArray(arr.Map, arr.Order)
func PHPAssociativeArray[T any](arr AssociativeArray[T]) unsafe.Pointer {
return phpArray[T](arr.Map, arr.Order)
}
func phpArray(entries map[string]any, order []string) unsafe.Pointer {
func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer {
var zendArray *C.HashTable
if len(order) != 0 {
@@ -195,7 +234,7 @@ func phpArray(entries map[string]any, order []string) unsafe.Pointer {
}
// EXPERIMENTAL: PHPPackedArray converts a Go slice to a PHP zval with a zend_array value.
func PHPPackedArray(slice []any) unsafe.Pointer {
func PHPPackedArray[T any](slice []T) unsafe.Pointer {
zendArray := createNewArray((uint32)(len(slice)))
for _, val := range slice {
zval := phpValue(val)
@@ -209,53 +248,116 @@ func PHPPackedArray(slice []any) unsafe.Pointer {
}
// EXPERIMENTAL: GoValue converts a PHP zval to a Go value
func GoValue(zval unsafe.Pointer) any {
return goValue((*C.zval)(zval))
//
// Zval having the null, bool, long, double, string and array types are currently supported.
// Arrays can curently only be converted to any[] and AssociativeArray[any].
// Any other type will cause an error.
// More types may be supported in the future.
func GoValue[T any](zval unsafe.Pointer) (T, error) {
return goValue[T]((*C.zval)(zval))
}
func goValue(zval *C.zval) any {
func goValue[T any](zval *C.zval) (res T, err error) {
var (
resAny any
resZero T
)
t := C.zval_get_type(zval)
switch t {
case C.IS_NULL:
return nil
resAny = any(nil)
case C.IS_FALSE:
return false
resAny = any(false)
case C.IS_TRUE:
return true
resAny = any(true)
case C.IS_LONG:
longPtr := (*C.zend_long)(extractZvalValue(zval, C.IS_LONG))
if longPtr != nil {
return int64(*longPtr)
v, err := extractZvalValue(zval, C.IS_LONG)
if err != nil {
return resZero, err
}
return int64(0)
if v != nil {
resAny = any(int64(*(*C.zend_long)(v)))
break
}
resAny = any(int64(0))
case C.IS_DOUBLE:
doublePtr := (*C.double)(extractZvalValue(zval, C.IS_DOUBLE))
if doublePtr != nil {
return float64(*doublePtr)
v, err := extractZvalValue(zval, C.IS_DOUBLE)
if err != nil {
return resZero, err
}
return float64(0)
if v != nil {
resAny = any(float64(*(*C.double)(v)))
break
}
resAny = any(float64(0))
case C.IS_STRING:
str := (*C.zend_string)(extractZvalValue(zval, C.IS_STRING))
if str == nil {
return ""
v, err := extractZvalValue(zval, C.IS_STRING)
if err != nil {
return resZero, err
}
return GoString(unsafe.Pointer(str))
if v == nil {
resAny = any("")
break
}
resAny = any(GoString(v))
case C.IS_ARRAY:
hashTable := (*C.HashTable)(extractZvalValue(zval, C.IS_ARRAY))
if hashTable != nil && htIsPacked(hashTable) {
return GoPackedArray(unsafe.Pointer(zval))
v, err := extractZvalValue(zval, C.IS_ARRAY)
if err != nil {
return resZero, err
}
return GoAssociativeArray(unsafe.Pointer(zval))
hashTable := (*C.HashTable)(v)
if hashTable != nil && htIsPacked(hashTable) {
typ := reflect.TypeOf(res)
if typ == nil || typ.Kind() == reflect.Interface && typ.NumMethod() == 0 {
r, e := GoPackedArray[any](unsafe.Pointer(zval))
if e != nil {
return resZero, e
}
resAny = any(r)
break
}
return resZero, fmt.Errorf("cannot convert packed array to non-any Go type %s", typ.String())
}
a, err := GoAssociativeArray[T](unsafe.Pointer(zval))
if err != nil {
return resZero, err
}
resAny = any(a)
default:
return nil
return resZero, fmt.Errorf("unsupported zval type %d", t)
}
if resAny == nil {
return resZero, nil
}
if castRes, ok := resAny.(T); ok {
return castRes, nil
}
return resZero, fmt.Errorf("cannot cast value of type %T to type %T", resAny, res)
}
// EXPERIMENTAL: PHPValue converts a Go any to a PHP zval
//
// nil, bool, int, int64, float64, string, []any, and map[string]any are currently supported.
// Any other type will cause a panic.
// More types may be supported in the future.
func PHPValue(value any) unsafe.Pointer {
return unsafe.Pointer(phpValue(value))
}
@@ -263,6 +365,10 @@ func PHPValue(value any) unsafe.Pointer {
func phpValue(value any) *C.zval {
var zval C.zval
if toZvalObj, ok := value.(toZval); ok {
return toZvalObj.toZval()
}
switch v := value.(type) {
case nil:
C.__zval_null__(&zval)
@@ -281,10 +387,8 @@ func phpValue(value any) *C.zval {
}
str := (*C.zend_string)(PHPString(v, false))
C.__zval_string__(&zval, str)
case AssociativeArray:
return (*C.zval)(PHPAssociativeArray(v))
case map[string]any:
return (*C.zval)(PHPAssociativeArray(AssociativeArray{Map: v}))
return (*C.zval)(PHPAssociativeArray[any](AssociativeArray[any]{Map: v}))
case []any:
return (*C.zval)(PHPPackedArray(v))
default:
@@ -308,25 +412,31 @@ func htIsPacked(ht *C.HashTable) bool {
}
// extractZvalValue returns a pointer to the zval value cast to the expected type
func extractZvalValue(zval *C.zval, expectedType C.uint8_t) unsafe.Pointer {
if zval == nil || C.zval_get_type(zval) != expectedType {
return nil
func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, error) {
if zval == nil {
if expectedType == C.IS_NULL {
return nil, nil
}
return nil, fmt.Errorf("zval type mismatch: expected %d, got nil", expectedType)
}
if zType := C.zval_get_type(zval); zType != expectedType {
return nil, fmt.Errorf("zval type mismatch: expected %d, got %d", expectedType, zType)
}
v := unsafe.Pointer(&zval.value[0])
switch expectedType {
case C.IS_LONG:
return v
case C.IS_DOUBLE:
return v
case C.IS_LONG, C.IS_DOUBLE:
return v, nil
case C.IS_STRING:
return unsafe.Pointer(*(**C.zend_string)(v))
return unsafe.Pointer(*(**C.zend_string)(v)), nil
case C.IS_ARRAY:
return unsafe.Pointer(*(**C.zend_array)(v))
default:
return nil
return unsafe.Pointer(*(**C.zend_array)(v)), nil
}
return nil, fmt.Errorf("unsupported zval type %d", expectedType)
}
func zvalPtrDtor(p unsafe.Pointer) {

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/exp/zapslog"
"go.uber.org/zap/zaptest"
)
@@ -53,13 +54,15 @@ func TestGoString(t *testing.T) {
func TestPHPMap(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalMap := map[string]any{
originalMap := map[string]string{
"foo1": "bar1",
"foo2": "bar2",
}
phpArray := PHPMap(originalMap)
defer zvalPtrDtor(phpArray)
defer zvalPtrDtor(phpArray)
convertedMap, err := GoMap[string](phpArray)
require.NoError(t, err)
assert.Equal(t, originalMap, GoMap(phpArray), "associative array should be equal after conversion")
})
@@ -67,8 +70,8 @@ func TestPHPMap(t *testing.T) {
func TestOrderedPHPAssociativeArray(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalArray := AssociativeArray{
Map: map[string]any{
originalArray := AssociativeArray[string]{
Map: map[string]string{
"foo1": "bar1",
"foo2": "bar2",
},
@@ -76,7 +79,9 @@ func TestOrderedPHPAssociativeArray(t *testing.T) {
}
phpArray := PHPAssociativeArray(originalArray)
defer zvalPtrDtor(phpArray)
defer zvalPtrDtor(phpArray)
convertedArray, err := GoAssociativeArray[string](phpArray)
require.NoError(t, err)
assert.Equal(t, originalArray, GoAssociativeArray(phpArray), "associative array should be equal after conversion")
})
@@ -84,10 +89,12 @@ func TestOrderedPHPAssociativeArray(t *testing.T) {
func TestPHPPackedArray(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalSlice := []any{"bar1", "bar2"}
originalSlice := []string{"bar1", "bar2"}
phpArray := PHPPackedArray(originalSlice)
phpArray := PHPPackedArray(originalSlice)
defer zvalPtrDtor(phpArray)
convertedSlice, err := GoPackedArray[string](phpArray)
require.NoError(t, err)
assert.Equal(t, originalSlice, GoPackedArray(phpArray), "slice should be equal after conversion")
})
@@ -95,14 +102,16 @@ func TestPHPPackedArray(t *testing.T) {
func TestPHPPackedArrayToGoMap(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalSlice := []any{"bar1", "bar2"}
expectedMap := map[string]any{
originalSlice := []string{"bar1", "bar2"}
expectedMap := map[string]string{
"0": "bar1",
"1": "bar2",
}
phpArray := PHPPackedArray(originalSlice)
phpArray := PHPPackedArray(originalSlice)
defer zvalPtrDtor(phpArray)
convertedMap, err := GoMap[string](phpArray)
require.NoError(t, err)
assert.Equal(t, expectedMap, GoMap(phpArray), "convert a packed to an associative array")
})
@@ -110,17 +119,19 @@ func TestPHPPackedArrayToGoMap(t *testing.T) {
func TestPHPAssociativeArrayToPacked(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalArray := AssociativeArray{
Map: map[string]any{
originalArray := AssociativeArray[string]{
Map: map[string]string{
"foo1": "bar1",
"foo2": "bar2",
},
Order: []string{"foo1", "foo2"},
}
expectedSlice := []any{"bar1", "bar2"}
expectedSlice := []string{"bar1", "bar2"}
phpArray := PHPAssociativeArray(originalArray)
defer zvalPtrDtor(phpArray)
convertedSlice, err := GoPackedArray[string](phpArray)
require.NoError(t, err)
assert.Equal(t, expectedSlice, GoPackedArray(phpArray), "convert an associative array to a slice")
})
@@ -131,12 +142,12 @@ func TestNestedMixedArray(t *testing.T) {
originalArray := map[string]any{
"string": "value",
"int": int64(123),
"float": float64(1.2),
"float": 1.2,
"true": true,
"false": false,
"nil": nil,
"packedArray": []any{"bar1", "bar2"},
"associativeArray": AssociativeArray{
"associativeArray": AssociativeArray[any]{
Map: map[string]any{"foo1": "bar1", "foo2": "bar2"},
Order: []string{"foo2", "foo1"},
},
@@ -144,6 +155,8 @@ func TestNestedMixedArray(t *testing.T) {
phpArray := PHPMap(originalArray)
defer zvalPtrDtor(phpArray)
convertedArray, err := GoMap[any](phpArray)
require.NoError(t, err)
assert.Equal(t, originalArray, GoMap(phpArray), "nested mixed array should be equal after conversion")
})