diff --git a/.github/workflows/Test.yaml b/.github/workflows/Test.yaml
index 3dfbffd7..6a635a43 100644
--- a/.github/workflows/Test.yaml
+++ b/.github/workflows/Test.yaml
@@ -17,7 +17,7 @@ jobs:
env:
runner: self-hosted
with:
- php-version: '8.3'
+ php-version: '8.4'
extensions: pcntl, xdebug
tools: composer
- name: Get composer cache directory
diff --git a/.github/workflows/build-cli.yaml b/.github/workflows/build-cli.yaml
index 05cb2571..a823a20c 100644
--- a/.github/workflows/build-cli.yaml
+++ b/.github/workflows/build-cli.yaml
@@ -13,51 +13,12 @@ on:
branches:
- v2
jobs:
- build-linux:
- strategy:
- fail-fast: false
- matrix:
- platform: [ 'amd64', 'arm64' ]
- runs-on: self-hosted
- steps:
- - uses: actions/checkout@v4
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.HUB_USERNAME }}
- password: ${{ secrets.HUB_PASSWORD }}
- - name: Configure QEMU
- uses: docker/setup-qemu-action@v3
- - name: Configure docker
- uses: docker/setup-buildx-action@v3
- with:
- platforms: ${{ matrix.platform }}
- - name: Expose GitHub Runtime
- uses: crazy-max/ghaction-github-runtime@v3
- - name: Build sources
- run: |
- echo "${{ secrets.GITHUB_TOKEN }}" > TOKEN
- docker buildx build --secret id=github-token,src=./TOKEN --cache-to type=gha,mode=max,scope=${{ matrix.platform }} --cache-from type=gha,scope=${{ matrix.platform }} --pull --load --platform linux/${{ matrix.platform }} --target cli-base-alpine -t builder .
- - name: Copy build
- run: |
- docker create --name builder builder
- docker cp builder:/go/src/app/cli/dist/dphp bin/dphp
- - name: Archive artifacts
- uses: actions/upload-artifact@v4
- with:
- name: dphp-${{ runner.os }}-${{ matrix.platform }}
- path: bin/dphp
build-docker:
runs-on: self-hosted
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.HUB_USERNAME }}
- password: ${{ secrets.HUB_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -104,7 +65,7 @@ jobs:
builder: ${{ steps.buildx.outputs.name }}
cache-from: type=gha,scope=image
cache-to: type=gha,mode=max,scope=image
- platforms: linux/amd64,linux/arm64
+ platforms: linux/amd64
- name: Build Test Image
uses: docker/build-push-action@v6
with:
@@ -118,53 +79,6 @@ jobs:
builder: ${{ steps.buildx.outputs.name }}
cache-from: type=gha,scope=image
platforms: linux/amd64
- build-osx:
- strategy:
- fail-fast: false
- matrix:
- platform: [ 'arm64', 'x86_64' ]
- runs-on: ${{ matrix.platform == 'arm64' && 'macos-14' || 'macos-13' }}
- env:
- HOMEBREW_NO_AUTO_UPDATE: 1
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
- with:
- cache-dependency-path: cli/go.sum
- go-version-file: cli/go.mod
- - name: Configure Version
- run: |
- if [ "${GITHUB_REF_TYPE}" == "tag" ]; then
- export VERSION=${GITHUB_REF_NAME:1}
- else
- export VERSION=${GITHUB_SHA}
- fi
-
- echo "VERSION=${VERSION}" >> "${GITHUB_ENV}"
- - name: Configure cache
- uses: actions/cache@v4
- with:
- path: dist
- key: ${{ matrix.platform }}-${{ hashFiles('cli/*.mod') }}
- - name: Run doctor
- run: |
- export GITHUB_TOKEN "${{ secrets.GITHUB_TOKEN }}"
- BUILD=no cli/build-php.sh
- - name: Build php
- run: |
- export GITHUB_TOKEN "${{ secrets.GITHUB_TOKEN }}"
- cli/build-php.sh
- - name: Build cli
- run: |
- export GITHUB_TOKEN "${{ secrets.GITHUB_TOKEN }}"
- cd cli && ./build.sh
- - run: ls -lah cli/dist/
- - run: ls -lah dist/ || true
- - name: Archive artifacts
- uses: actions/upload-artifact@v4
- with:
- name: dphp-${{ runner.os }}-${{ matrix.platform }}
- path: cli/dist/dphp
performance-test:
name: Performance Test
needs:
diff --git a/.idea/codeception.xml b/.idea/codeception.xml
deleted file mode 100644
index 3e191480..00000000
--- a/.idea/codeception.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/durable-php.iml b/.idea/durable-php.iml
index 90a1c862..e72518c8 100644
--- a/.idea/durable-php.iml
+++ b/.idea/durable-php.iml
@@ -40,7 +40,6 @@
-
@@ -109,6 +108,10 @@
+
+
+
+
diff --git a/.idea/php.xml b/.idea/php.xml
index 0e79208f..6301b492 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -6,6 +6,9 @@
+
+
+
@@ -29,6 +32,9 @@
+
+
+
@@ -129,10 +135,7 @@
-
-
-
@@ -140,6 +143,10 @@
+
+
+
+
@@ -208,7 +215,7 @@
-
+
diff --git a/.idea/phpspec.xml b/.idea/phpspec.xml
index 234a9fdb..7a64ebae 100644
--- a/.idea/phpspec.xml
+++ b/.idea/phpspec.xml
@@ -5,108 +5,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 57d6c506..ad66379f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
-FROM golang:1.24.0-alpine AS golang-base
-FROM php:8.4.3-zts AS php-base
+FROM golang:1.24.5-alpine AS golang-base
+FROM php:8.4.10-zts AS php-base
FROM golang-base AS cli-base-alpine
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
@@ -29,21 +29,21 @@ RUN apk update; \
m4 \
make \
pkgconfig \
- php83 \
- php83-common \
- php83-ctype \
- php83-curl \
- php83-dom \
- php83-mbstring \
- php83-openssl \
- php83-pcntl \
- php83-phar \
- php83-posix \
- php83-session \
- php83-sodium \
- php83-tokenizer \
- php83-xml \
- php83-xmlwriter \
+ php84 \
+ php84-common \
+ php84-ctype \
+ php84-curl \
+ php84-dom \
+ php84-mbstring \
+ php84-openssl \
+ php84-pcntl \
+ php84-phar \
+ php84-posix \
+ php84-session \
+ php84-sodium \
+ php84-tokenizer \
+ php84-xml \
+ php84-xmlwriter \
upx \
wget \
xz ; \
@@ -54,6 +54,7 @@ ENV PHP_EXTENSIONS="apcu,bcmath,bz2,calendar,ctype,curl,dba,dom,exif,fileinfo,fi
ENV PHP_EXTENSION_LIBS="bzip2,freetype,libavif,libjpeg,libwebp,libzip"
WORKDIR /go/src/app
+COPY .git /go/src/app/.git
COPY cli/build-php.sh .
RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) BUILD=no ./build-php.sh
RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) ./build-php.sh
@@ -107,7 +108,7 @@ ENV GOBIN=/usr/local/bin
RUN go get durable_php
#RUN go test ./...
-RUN go install -ldflags "-w -s -X 'main.version=$VERSION'"
+RUN CGO_CFLAGS=$(php-config --includes) CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" go install --tags nowatcher -ldflags "-w -s -X 'main.version=$VERSION'"
FROM common AS durable-php
COPY --from=builder /usr/local/bin/durable_php /usr/local/bin/dphp
diff --git a/cli/Makefile b/cli/Makefile
index 3953e668..1b938225 100644
--- a/cli/Makefile
+++ b/cli/Makefile
@@ -1,17 +1,17 @@
-TARGET := dphp-linux-*
-BIN_PATH := ../bin
-DOCKER_IMAGE := builder
-DOCKER_TARGET := cli-base-alpine
-BUILD_PATH := /go/src/app/cli/dist
-
-${BIN_PATH}/${TARGET}: cli.go */* go.mod build.sh build-php.sh ../Dockerfile
- mkdir -p ${BIN_PATH}
- cd .. && docker buildx build --pull --load --target ${DOCKER_TARGET} -t ${DOCKER_IMAGE} .
- docker create --name ${DOCKER_IMAGE} ${DOCKER_IMAGE} || ( docker rm -f ${DOCKER_IMAGE} && false )
- docker cp ${DOCKER_IMAGE}:${BUILD_PATH}/dphp ${BIN_PATH}/ || ( docker rm -f ${DOCKER_IMAGE} && false )
- docker rm -f ${DOCKER_IMAGE}
- upx -9 --force-pie ../bin/dphp-*
-
-../dist: ${BIN_PATH}/${TARGET}
- docker create --name builder builder
- docker cp ${DOCKER_IMAGE}:${BUILD_PATH} ../dist
+TARGET := dphp-linux-*
+BIN_PATH := ../bin
+DOCKER_IMAGE := builder
+DOCKER_TARGET := cli-base-alpine
+BUILD_PATH := /go/src/app/cli/dist
+
+${BIN_PATH}/${TARGET}: cli.go */* go.mod build.sh build-php.sh ../Dockerfile
+ mkdir -p ${BIN_PATH}
+ cd .. && docker buildx build --pull --load --target ${DOCKER_TARGET} -t ${DOCKER_IMAGE} .
+ docker create --name ${DOCKER_IMAGE} ${DOCKER_IMAGE} || ( docker rm -f ${DOCKER_IMAGE} && false )
+ docker cp ${DOCKER_IMAGE}:${BUILD_PATH}/dphp ${BIN_PATH}/ || ( docker rm -f ${DOCKER_IMAGE} && false )
+ docker rm -f ${DOCKER_IMAGE}
+ upx -9 --force-pie ../bin/dphp-*
+
+../dist: ${BIN_PATH}/${TARGET}
+ docker create --name builder builder
+ docker cp ${DOCKER_IMAGE}:${BUILD_PATH} ../dist
diff --git a/cli/auth/keys.go b/cli/auth/keys.go
index fef356e0..83445308 100644
--- a/cli/auth/keys.go
+++ b/cli/auth/keys.go
@@ -35,6 +35,11 @@ func DecorateContextWithUser(ctx context.Context, user *User) context.Context {
return context.WithValue(ctx, appcontext.CurrentUserKey, user)
}
+func GetUserFromContext(ctx context.Context) *User {
+ user, _ := ctx.Value(appcontext.CurrentUserKey).(*User)
+ return user
+}
+
// ExtractUser extracts user information from the Authorization token in the HTTP request header.
// It returns the user and a boolean indicating if the extraction was successful.
//
@@ -109,13 +114,21 @@ func ExtractUser(r *http.Request, config *config.Config) (user *User, ok bool) {
// The token is signed using the active secret key from the config.
// The token will expire in 72 hours and is valid starting from 5 minutes ago.
// Returns the signed token string or an error if the signing process fails.
-func CreateUser(userId UserId, role []Role, config *config.Config) (string, error) {
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+func CreateUser(userId UserId, role []Role, claims map[string]interface{}, config *config.Config) (string, error) {
+ claimMap := jwt.MapClaims{
"sub": userId,
"exp": time.Now().Add(72 * time.Hour).Unix(),
"iat": time.Now().Add(-5 * time.Minute).Unix(),
+ "nbf": time.Now().Add(-5 * time.Minute).Unix(),
"roles": role,
- })
+ }
+
+ for k, v := range claims {
+ k = strings.TrimSpace(k)
+ claimMap[k] = v
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claimMap)
key, err := getActiveKey(config)
if err != nil {
diff --git a/cli/auth/resource.go b/cli/auth/resource.go
index 681a7672..899c80d9 100644
--- a/cli/auth/resource.go
+++ b/cli/auth/resource.go
@@ -4,6 +4,7 @@ import (
"context"
"durable_php/appcontext"
"durable_php/glue"
+ "durable_php/ids"
"encoding/json"
"errors"
"fmt"
@@ -50,7 +51,7 @@ type Resource struct {
Mode Mode `json:"mode"`
mu sync.RWMutex
kv jetstream.KeyValue
- id *glue.StateId
+ id *ids.StateId
Expires time.Time
revision uint64
}
@@ -107,7 +108,7 @@ func (r *Resource) ShareOwnership(newUser UserId, currentUser *User, keepPermiss
return nil
}
-func (r *Resource) ApplyPerms(id *glue.StateId, ctx context.Context, logger *zap.Logger) bool {
+func (r *Resource) ApplyPerms(id *ids.StateId, ctx context.Context, logger *zap.Logger) bool {
perms, err := r.getOrCreatePermissions(id, ctx, logger)
if err != nil {
logger.Error("failed to get permissions", zap.Error(err))
@@ -122,7 +123,7 @@ func (r *Resource) ApplyPerms(id *glue.StateId, ctx context.Context, logger *zap
}
// CanCreate Load permissions from cache if available, otherwise fetch from external source
-func (r *Resource) CanCreate(id *glue.StateId, ctx context.Context, logger *zap.Logger) bool {
+func (r *Resource) CanCreate(id *ids.StateId, ctx context.Context, logger *zap.Logger) bool {
perms, err := r.getOrCreatePermissions(id, ctx, logger)
if err != nil {
logger.Error("failed to create permissions", zap.Error(err))
@@ -131,7 +132,7 @@ func (r *Resource) CanCreate(id *glue.StateId, ctx context.Context, logger *zap.
return r.isUserPermitted(perms, ctx)
}
-func (r *Resource) getOrCreatePermissions(id *glue.StateId, ctx context.Context, logger *zap.Logger) (CreatePermissions, error) {
+func (r *Resource) getOrCreatePermissions(id *ids.StateId, ctx context.Context, logger *zap.Logger) (CreatePermissions, error) {
var perms CreatePermissions
if cached, found := cache.Load(id.Name()); found {
perms = cached.(CreatePermissions)
diff --git a/cli/auth/resourceManager.go b/cli/auth/resourceManager.go
index 1d5989cf..facc4f5c 100644
--- a/cli/auth/resourceManager.go
+++ b/cli/auth/resourceManager.go
@@ -4,10 +4,13 @@ import (
"context"
"durable_php/appcontext"
"durable_php/glue"
+ "durable_php/ids"
+ "encoding/json"
"github.com/modern-go/concurrent"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"go.uber.org/zap"
+ "maps"
"time"
)
@@ -44,7 +47,7 @@ func GetResourceManager(ctx context.Context, stream jetstream.JetStream) *Resour
// DiscoverResource is a method of the ResourceManager struct that is responsible for discovering a resource based on
// the provided context, state ID, logger, and preventCreation flag
-func (r *ResourceManager) DiscoverResource(ctx context.Context, id *glue.StateId, logger *zap.Logger, preventCreation bool) (*Resource, error) {
+func (r *ResourceManager) DiscoverResource(ctx context.Context, id *ids.StateId, logger *zap.Logger, preventCreation bool) (*Resource, error) {
currentUser, _ := ctx.Value(appcontext.CurrentUserKey).(*User)
data, err := r.kv.Get(ctx, id.ToSubject().String())
@@ -87,6 +90,47 @@ func (r *ResourceManager) DiscoverResource(ctx context.Context, id *glue.StateId
return resource, nil
}
+func (r *ResourceManager) ToAuthContext(ctx context.Context, resource *Resource) ([]byte, error) {
+ owners := []map[string]interface{}{}
+
+ for o, _ := range resource.Owners {
+ owners = append(owners, map[string]interface{}{
+ "shareType": "owner",
+ "subject": string(o),
+ "allowed": []string{string(Owner)},
+ })
+ }
+
+ shares := []map[string]interface{}{}
+
+ for _, s := range resource.Shares {
+ if u, ok := s.(*UserShare); ok {
+ shares = append(shares, map[string]interface{}{
+ "shareType": "user",
+ "subject": string(u.UserId),
+ "allowed": maps.Keys(u.AllowedOperations),
+ })
+ }
+ if r, ok := s.(*RoleShare); ok {
+ shares = append(shares, map[string]interface{}{
+ "shareType": "role",
+ "subject": string(r.Role),
+ "allowed": maps.Keys(r.AllowedOperations),
+ })
+ }
+ }
+
+ c := map[string]interface{}{
+ "contextId": map[string]string{
+ "id": resource.id.String(),
+ },
+ "owners": owners,
+ "shares": shares,
+ }
+
+ return json.Marshal(c)
+}
+
// ScheduleDelete is a method of the ResourceManager struct that is responsible for scheduling the deletion of a
// resource based on the provided context, resource, and time. It deletes the resource from the key-value store and
// publishes a delete message to NATS JetStream with a delay specified by the provided time. The resource is identified
diff --git a/cli/build-php.sh b/cli/build-php.sh
deleted file mode 100755
index b68c88ec..00000000
--- a/cli/build-php.sh
+++ /dev/null
@@ -1,124 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright ©2024 Robert Landers
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the “Software”), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
-# OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
-# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-#
-
-set -o errexit
-
-if ! type "git" >/dev/null; then
- echo "The \"git\" command must be installed."
- exit 1
-fi
-
-os="$(uname -s | tr '[:upper:]' '[:lower:]')"
-
-export CFLAGS="$CFLAGS -O2" CXXFLAGS="$CXXFLAGS -O2"
-
-if [ -z "${PHP_EXTENSIONS}" ]; then
- export PHP_EXTENSIONS="apcu,bcmath,bz2,calendar,ctype,curl,dom,exif,fileinfo,filter,gmp,gd,iconv,igbinary,mbregex,mbstring,opcache,openssl,pcntl,phar,posix,readline,simplexml,sockets,sodium,sysvsem,tokenizer,uv,xml,xmlreader,xmlwriter,zip,zlib"
-fi
-
-if [ -z "${PHP_EXTENSION_LIBS}" ]; then
- export PHP_EXTENSION_LIBS="bzip2,freetype,libavif,libjpeg,libwebp,libzip"
-fi
-
-if [ -z "${PHP_VERSION}" ]; then
- export PHP_VERSION="8.3"
-fi
-
-if [ -z "${FRANKENPHP_VERSION}" ]; then
- FRANKENPHP_VERSION="dev"
- export FRANKENPHP_VERSION
-elif [ -d ".git/" ]; then
- CURRENT_REF="$(git rev-parse --abbrev-ref HEAD)"
- export CURRENT_REF
-
- if echo "${FRANKENPHP_VERSION}" | grep -F -q "."; then
- # Tag
-
- # Trim "v" prefix if any
- FRANKENPHP_VERSION=${FRANKENPHP_VERSION#v}
- export FRANKENPHP_VERSION
-
- git checkout "v${FRANKENPHP_VERSION}"
- else
- git checkout "${FRANKENPHP_VERSION}"
- fi
-fi
-
-if [ -n "${CLEAN}" ]; then
- rm -Rf dist/
- go clean -cache
-fi
-
-# Build libphp if necessary
-if [ -f "dist/static-php-cli/buildroot/lib/libphp.a" ]; then
- cd dist/static-php-cli
-else
- mkdir -p dist/
- cd dist/
-
- if [ -d "static-php-cli/" ]; then
- cd static-php-cli/
- git pull
- else
- git clone --depth 1 --branch main https://github.com/crazywhalecc/static-php-cli
- cd static-php-cli/
- fi
-
- if type "brew" >/dev/null; then
- if ! type "composer" >/dev/null; then
- packages="composer"
- fi
- if ! type "go" >/dev/null; then
- packages="${packages} go"
- fi
- if [ -n "${RELEASE}" ] && ! type "gh" >/dev/null; then
- packages="${packages} gh"
- fi
-
- if [ -n "${packages}" ]; then
- # shellcheck disable=SC2086
- brew install --formula --quiet ${packages}
- fi
- fi
-
- composer install --no-dev -a
-
- if [ "${os}" = "linux" ]; then
- extraOpts="--disable-opcache-jit -I "memory_limit=2G" -I "opcache.enable_cli=1" -I "opcache.enable=1""
- echo ""
- fi
-
- if [ -n "${DEBUG_SYMBOLS}" ]; then
- extraOpts="${extraOpts} --no-strip"
- fi
-
- ./bin/spc doctor
- ./bin/spc fetch --with-php="${PHP_VERSION}" --for-extensions="${PHP_EXTENSIONS}"
- # the Brotli library must always be built as it is required by http://github.com/dunglas/caddy-cbrotli
- # shellcheck disable=SC2086
-
- if [ -z $BUILD ]; then
- ./bin/spc build --debug --enable-zts --build-embed ${extraOpts} "${PHP_EXTENSIONS}" --with-libs="brotli,${PHP_EXTENSION_LIBS}"
- fi
-fi
diff --git a/cli/build.sh b/cli/build.sh
deleted file mode 100755
index d745c407..00000000
--- a/cli/build.sh
+++ /dev/null
@@ -1,135 +0,0 @@
-#!/bin/sh
-
-#
-# Copyright ©2024 Robert Landers
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the “Software”), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT
-# OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
-# OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-#
-
-set -o errexit
-
-if ! type "git" >/dev/null; then
- echo "The \"git\" command must be installed."
- exit 1
-fi
-
-cd ../dist/static-php-cli
-
-arch="$(uname -m)"
-os="$(uname -s | tr '[:upper:]' '[:lower:]')"
-md5binary="md5sum"
-if [ "${os}" = "darwin" ]; then
- os="mac"
- md5binary="md5 -q"
-fi
-
-if [ -z "${PHP_EXTENSIONS}" ]; then
- export PHP_EXTENSIONS="apcu,bcmath,bz2,calendar,ctype,curl,dom,exif,fileinfo,filter,gmp,gd,iconv,igbinary,mbregex,mbstring,opcache,openssl,pcntl,phar,posix,readline,simplexml,sockets,sodium,sysvsem,tokenizer,uuid,uv,xml,xmlreader,xmlwriter,zip,zlib"
-fi
-
-if [ -z "${PHP_EXTENSION_LIBS}" ]; then
- export PHP_EXTENSION_LIBS="bzip2,freetype,libavif,libjpeg,libwebp,libzip"
-fi
-
-if [ -z "${PHP_VERSION}" ]; then
- export PHP_VERSION="8.3"
-fi
-
-if [ -z "${FRANKENPHP_VERSION}" ]; then
- FRANKENPHP_VERSION="dev"
- export FRANKENPHP_VERSION
-elif [ -d ".git/" ]; then
- CURRENT_REF="$(git rev-parse --abbrev-ref HEAD)"
- export CURRENT_REF
-
- if echo "${FRANKENPHP_VERSION}" | grep -F -q "."; then
- # Tag
-
- # Trim "v" prefix if any
- FRANKENPHP_VERSION=${FRANKENPHP_VERSION#v}
- export FRANKENPHP_VERSION
-
- git checkout "v${FRANKENPHP_VERSION}"
- else
- git checkout "${FRANKENPHP_VERSION}"
- fi
-fi
-
-bin="dphp"
-
-if [ -n "${CLEAN}" ]; then
- rm -Rf dist/
- go clean -cache
-fi
-
-CGO_CFLAGS="-O2 -DFRANKENPHP_VERSION=${FRANKENPHP_VERSION} -I${PWD}/buildroot/include/ $(./buildroot/bin/php-config --includes | sed s\#-I/\#-I"${PWD}"/buildroot/\#g)"
-if [ -n "${DEBUG_SYMBOLS}" ]; then
- CGO_CFLAGS="-g ${CGO_CFLAGS}"
-fi
-export CGO_CFLAGS
-
-if [ "${os}" = "mac" ]; then
- export CGO_LDFLAGS="-framework CoreFoundation -framework SystemConfiguration"
-fi
-
-CGO_LDFLAGS="${CGO_LDFLAGS} ${PWD}/buildroot/lib/libbrotlicommon.a ${PWD}/buildroot/lib/libbrotlienc.a ${PWD}/buildroot/lib/libbrotlidec.a $(./buildroot/bin/php-config --ldflags) $(./buildroot/bin/php-config --libs) -lstdc++ -lbrotlidec -lssl -lcrypto -lbrotlienc -lbrotlicommon"
-export CGO_LDFLAGS
-
-LIBPHP_VERSION="$(./buildroot/bin/php-config --version)"
-export LIBPHP_VERSION
-
-cd ../../cli
-
-VERSION="$(git describe --tags --always)"
-if git status --porcelain | grep -q "cli"; then
- VERSION="$VERSION-dirty"
-fi
-
-# Embed PHP app, if any
-if [ -n "${EMBED}" ] && [ -d "${EMBED}" ]; then
- tar -cf app.tar -C "${EMBED}" .
- ${md5binary} app.tar >app_checksum.txt
-fi
-
-if [ "${os}" = "linux" ]; then
- extraExtldflags="-Wl,-z,stack-size=0x80000"
-fi
-
-if [ -z "${DEBUG_SYMBOLS}" ]; then
- extraLdflags="-w -s -race"
-fi
-
-env
-go env
-go get durable_php
-go build -buildmode=pie -tags "cgo netgo nats osusergo static_build" -ldflags "-linkmode=external -extldflags '-static-pie ${extraExtldflags}' ${extraLdflags} -X 'github.com/caddyserver/caddy/v2.CustomVersion=FrankenPHP ${FRANKENPHP_VERSION} PHP ${LIBPHP_VERSION} go_durable_php' -X 'main.version=$VERSION'" -o "dist/${bin}" durable_php
-
-if [ -d "${EMBED}" ]; then
- truncate -s 0 app.tar
- truncate -s 0 app_checksum.txt
-fi
-
-if [ -z "${NO_COMPRESS}" ]; then
- if type "upx" >/dev/null; then
- #upx --best "dist/${bin}"
- echo "would compress"
- fi
-fi
-
-"dist/${bin}" version
diff --git a/cli/cli.go b/cli/cli.go
index 67ed5d9e..53017760 100644
--- a/cli/cli.go
+++ b/cli/cli.go
@@ -27,6 +27,7 @@ import (
"durable_php/auth"
"durable_php/config"
"durable_php/glue"
+ "durable_php/ids"
di "durable_php/init"
"durable_php/lib"
"encoding/json"
@@ -44,7 +45,6 @@ import (
"os"
"os/signal"
"runtime"
- "runtime/pprof"
"strings"
"sync"
"syscall"
@@ -91,14 +91,14 @@ func execute(args []string, options map[string]string) int {
defer os.RemoveAll(data)
- profile, err := os.CreateTemp("", "")
- if err != nil {
- panic(err)
- }
- err = pprof.StartCPUProfile(profile)
- if err != nil {
- panic(err)
- }
+ //profile, err := os.CreateTemp("", "")
+ //if err != nil {
+ // panic(err)
+ //}
+ //err = pprof.StartCPUProfile(profile)
+ //if err != nil {
+ // panic(err)
+ //}
go func() {
sigs := make(chan os.Signal, 1)
@@ -107,10 +107,10 @@ func execute(args []string, options map[string]string) int {
<-sigs
- pprof.StopCPUProfile()
- profile.Close()
+ //pprof.StopCPUProfile()
+ //profile.Close()
- logger.Warn("Profile output", zap.String("Filename", profile.Name()))
+ //logger.Warn("Profile output", zap.String("Filename", profile.Name()))
os.RemoveAll(data)
os.Exit(0)
@@ -181,9 +181,9 @@ func execute(args []string, options map[string]string) int {
})
consumers := []string{
- string(glue.Activity),
- string(glue.Entity),
- string(glue.Orchestration),
+ string(ids.Activity),
+ string(ids.Entity),
+ string(ids.Orchestration),
}
for _, kind := range consumers {
@@ -201,7 +201,7 @@ func execute(args []string, options map[string]string) int {
panic(err)
}
- opts := []frankenphp.Option{frankenphp.WithNumThreads(runtime.NumCPU() * 2), frankenphp.WithLogger(logger)}
+ opts := []frankenphp.Option{frankenphp.WithNumThreads(runtime.NumCPU() * 2)}
if err := frankenphp.Init(opts...); err != nil {
panic(err)
@@ -212,30 +212,30 @@ func execute(args []string, options map[string]string) int {
if options["no-activities"] != "true" {
logger.Info("Starting activity consumer")
- go lib.BuildConsumer(stream, ctx, cfg, glue.Activity, logger, js, rm)
+ go lib.BuildConsumer(stream, ctx, cfg, ids.Activity, logger, js, rm)
}
if options["no-entities"] != "true" {
logger.Info("Starting entity consumer")
- go lib.BuildConsumer(stream, ctx, cfg, glue.Entity, logger, js, rm)
+ go lib.BuildConsumer(stream, ctx, cfg, ids.Entity, logger, js, rm)
}
if options["no-orchestrations"] != "true" {
logger.Info("Starting orchestration consumer")
- go lib.BuildConsumer(stream, ctx, cfg, glue.Orchestration, logger, js, rm)
+ go lib.BuildConsumer(stream, ctx, cfg, ids.Orchestration, logger, js, rm)
}
if len(cfg.Extensions.Search.Collections) > 0 {
for _, collection := range cfg.Extensions.Search.Collections {
switch collection {
case "entities":
- err := lib.IndexerListen(ctx, cfg, glue.Entity, js, logger)
+ err := lib.IndexerListen(ctx, cfg, ids.Entity, js, logger)
if err != nil {
cfg.Extensions.Search.Collections = []string{}
logger.Warn("Disabling search extension due to failing to connect to typesense")
}
case "orchestrations":
- err := lib.IndexerListen(ctx, cfg, glue.Orchestration, js, logger)
+ err := lib.IndexerListen(ctx, cfg, ids.Orchestration, js, logger)
if err != nil {
cfg.Extensions.Search.Collections = []string{}
logger.Warn("Disabling search extension due to failing to connect to typesense")
@@ -452,13 +452,13 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- var store glue.IdKind
+ var store ids.IdKind
switch args[0] {
- case string(glue.Orchestration):
- store = glue.Orchestration
+ case string(ids.Orchestration):
+ store = ids.Orchestration
if len(args) == 1 {
- kv, err := js.KeyValue(ctx, string(glue.Orchestration))
+ kv, err := js.KeyValue(ctx, string(ids.Orchestration))
if err != nil {
fmt.Println("[]")
return 0
@@ -479,10 +479,10 @@ func main() {
fmt.Println(string(marshal))
return 0
}
- case string(glue.Activity):
- store = glue.Activity
- case string(glue.Entity):
- store = glue.Entity
+ case string(ids.Activity):
+ store = ids.Activity
+ case string(ids.Entity):
+ store = ids.Entity
default:
panic(fmt.Errorf("invalid type: %s", args[0]))
}
@@ -503,14 +503,14 @@ func main() {
return 0
}
- var id *glue.StateId
+ var id *ids.StateId
switch store {
- case glue.Entity:
+ case ids.Entity:
fallthrough
- case glue.Orchestration:
- id = glue.ParseStateId(fmt.Sprintf("%s:%s:%s", string(store), args[1], args[2]))
- case glue.Activity:
- id = glue.ParseStateId(fmt.Sprintf("%s:%s", string(glue.Activity), args[0]))
+ case ids.Orchestration:
+ id = ids.ParseStateId(fmt.Sprintf("%s:%s:%s", string(store), args[1], args[2]))
+ case ids.Activity:
+ id = ids.ParseStateId(fmt.Sprintf("%s:%s", string(ids.Activity), args[0]))
}
ctx, cancel = context.WithCancel(ctx)
@@ -530,6 +530,8 @@ func main() {
createUser := cli.NewCommand("create-user", "Create a new user").
WithArg(cli.NewArg("id", "The user id to assign to the user").WithType(cli.TypeString)).
WithOption(cli.NewOption("admin", "Create the user as an admin").WithType(cli.TypeBool)).
+ WithOption(cli.NewOption("roles", "Create with the roles").WithType(cli.TypeString).WithChar('r')).
+ WithOption(cli.NewOption("claims", "Create with the claims as key:value;key:value").WithType(cli.TypeString).WithChar('c')).
WithAction(func(args []string, options map[string]string) int {
cfg, err := config.GetProjectConfig()
if err != nil {
@@ -541,7 +543,28 @@ func main() {
rol = append(rol, "admin")
}
- user, err := auth.CreateUser(auth.UserId(args[0]), rol, cfg)
+ roles := strings.Split(options["roles"], ",")
+ for _, role := range roles {
+ rol = append(rol, auth.Role(role))
+ }
+
+ extraClaims := make(map[string]interface{})
+ if options["claims"] != "" {
+ claims := strings.Split(options["claims"], ";")
+ for _, claim := range claims {
+ kv := strings.Split(claim, ":")
+ if len(kv) != 2 {
+ panic(fmt.Errorf("invalid claim: %s", claim))
+ }
+ if strings.Contains(kv[1], ",") {
+ extraClaims[kv[0]] = strings.Split(strings.TrimSpace(kv[1]), ",")
+ } else {
+ extraClaims[kv[0]] = strings.TrimSpace(kv[1])
+ }
+ }
+ }
+
+ user, err := auth.CreateUser(auth.UserId(args[0]), rol, extraClaims, cfg)
if err != nil {
return 1
}
diff --git a/cli/glue/glue.go b/cli/glue/glue.go
index 9ba9e784..42bfefc1 100644
--- a/cli/glue/glue.go
+++ b/cli/glue/glue.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"durable_php/appcontext"
+ "durable_php/ids"
"encoding/json"
"fmt"
"github.com/dunglas/frankenphp"
@@ -72,7 +73,7 @@ func NewGlue(bootstrap string, function Method, input []any, payload string) *Gl
}
}
-func FromApiRequest(ctx context.Context, r *http.Request, function Method, logger *zap.Logger, stream jetstream.JetStream, id *StateId, headers http.Header) ([]*nats.Msg, string, error, *http.Header, bool) {
+func FromApiRequest(ctx context.Context, r *http.Request, function Method, logger *zap.Logger, stream jetstream.JetStream, id *ids.StateId, headers http.Header) ([]*nats.Msg, string, error, *http.Header, bool) {
temp, err := os.CreateTemp("", "reqbody")
if err != nil {
return nil, "", err, nil, false
@@ -111,7 +112,7 @@ func FromApiRequest(ctx context.Context, r *http.Request, function Method, logge
return msgs, temp.Name(), nil, &responseHeaders, deleteAfter
}
-func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Logger, env map[string]string, stream jetstream.JetStream, id *StateId) ([]*nats.Msg, http.Header, int, bool) {
+func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Logger, env map[string]string, stream jetstream.JetStream, id *ids.StateId) ([]*nats.Msg, http.Header, int, bool) {
var dir string
var ok bool
if dir, ok = GetLibraryDir("glue.php"); !ok {
@@ -168,7 +169,7 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log
Response: nil,
}
- r, err = frankenphp.NewRequestWithContext(r, frankenphp.WithRequestLogger(logger), frankenphp.WithRequestEnv(env))
+ r, err = frankenphp.NewRequestWithContext(r, frankenphp.WithRequestEnv(env))
if err != nil {
panic(err)
}
@@ -192,7 +193,7 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log
go func() {
mu := sync.Mutex{}
for query := range writer.query {
- id := ParseStateId(query[0])
+ id := ids.ParseStateId(query[0])
qid := query[1]
wg.Add(1)
go func() {
@@ -219,11 +220,11 @@ func (g *Glue) Execute(ctx context.Context, headers http.Header, logger *zap.Log
return writer.events, writer.Header(), writer.status, writer.DeleteAfter
}
-func DeleteState(ctx context.Context, stream jetstream.JetStream, logger *zap.Logger, id *StateId) error {
+func DeleteState(ctx context.Context, stream jetstream.JetStream, logger *zap.Logger, id *ids.StateId) error {
logger.Info("Deleting state", zap.Any("id", id))
- if id.Kind == Orchestration {
+ if id.Kind == ids.Orchestration {
bucket, err := stream.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
- Bucket: string(Orchestration),
+ Bucket: string(ids.Orchestration),
Compression: true,
})
if err != nil {
@@ -251,11 +252,11 @@ func DeleteState(ctx context.Context, stream jetstream.JetStream, logger *zap.Lo
return nil
}
-func GetStateFile(id *StateId, stream jetstream.JetStream, ctx context.Context, logger *zap.Logger) (*os.File, func() error) {
- if id.Kind == Orchestration {
+func GetStateFile(id *ids.StateId, stream jetstream.JetStream, ctx context.Context, logger *zap.Logger) (*os.File, func() error) {
+ if id.Kind == ids.Orchestration {
// orchestrations use optimistic concurrency and the kv store for state
bucket, err := stream.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
- Bucket: string(Orchestration),
+ Bucket: string(ids.Orchestration),
Description: "Holds orchestration state and history",
Compression: true,
})
diff --git a/cli/glue/response_writer.go b/cli/glue/response_writer.go
index 0ea2c401..4d57346e 100644
--- a/cli/glue/response_writer.go
+++ b/cli/glue/response_writer.go
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"durable_php/appcontext"
+ "durable_php/ids"
"encoding/json"
"github.com/nats-io/nats.go"
"go.uber.org/zap"
@@ -53,7 +54,7 @@ type InternalLoggingResponseWriter struct {
events []*nats.Msg
query chan []string
headers http.Header
- CurrentId *StateId
+ CurrentId *ids.StateId
Context context.Context
DeleteAfter bool
}
@@ -78,10 +79,10 @@ func (w *InternalLoggingResponseWriter) Write(b []byte) (int, error) {
return len(b), err
}
- destinationId := ParseStateId(body.Destination)
+ destinationId := ids.ParseStateId(body.Destination)
replyTo := ""
if body.ReplyTo != "" {
- replyTo = ParseStateId(body.ReplyTo).ToSubject().String()
+ replyTo = ids.ParseStateId(body.ReplyTo).ToSubject().String()
}
now, _ := time.Now().MarshalText()
diff --git a/cli/glue/state.go b/cli/glue/state.go
index 46e8ce4e..50693ffc 100644
--- a/cli/glue/state.go
+++ b/cli/glue/state.go
@@ -2,10 +2,11 @@ package glue
import (
"context"
+ "durable_php/ids"
"github.com/nats-io/nats.go/jetstream"
)
-func GetObjectStore(kind IdKind, js jetstream.JetStream, ctx context.Context) (jetstream.ObjectStore, error) {
+func GetObjectStore(kind ids.IdKind, js jetstream.JetStream, ctx context.Context) (jetstream.ObjectStore, error) {
obj, err := js.CreateOrUpdateObjectStore(ctx, jetstream.ObjectStoreConfig{
Bucket: string(kind),
diff --git a/cli/go.mod b/cli/go.mod
index af861831..eb4048f9 100644
--- a/cli/go.mod
+++ b/cli/go.mod
@@ -1,11 +1,12 @@
module durable_php
-go 1.23
-require github.com/dunglas/frankenphp v1.4.4
+go 1.24.5
-require github.com/nats-io/nats.go v1.38.0
+require github.com/dunglas/frankenphp v1.9.0
-require github.com/nats-io/nats-server/v2 v2.10.24
+require github.com/nats-io/nats.go v1.43.0
+
+require github.com/nats-io/nats-server/v2 v2.11.6
require github.com/teris-io/cli v1.0.1
@@ -15,7 +16,7 @@ require github.com/gorilla/mux v1.8.1
require github.com/typesense/typesense-go v1.1.0
-require github.com/golang-jwt/jwt/v4 v4.5.1
+require github.com/golang-jwt/jwt/v4 v4.5.2
require (
github.com/google/uuid v1.6.0
@@ -29,26 +30,26 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
- github.com/gammazero/deque v1.0.0 // indirect
- github.com/klauspost/compress v1.17.11 // indirect
+ github.com/gammazero/deque v1.1.0 // indirect
+ github.com/google/go-tpm v0.9.5 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
github.com/maypok86/otter v1.2.4 // indirect
github.com/minio/highwayhash v1.0.3 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/nats-io/jwt/v2 v2.7.3 // indirect
- github.com/nats-io/nkeys v0.4.9 // indirect
+ github.com/nats-io/jwt/v2 v2.7.4 // indirect
+ github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/oapi-codegen/runtime v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/prometheus/client_golang v1.21.0 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.62.0 // indirect
- github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/prometheus/client_golang v1.22.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.65.0 // indirect
+ github.com/prometheus/procfs v0.17.0 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.31.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/text v0.22.0 // indirect
- golang.org/x/time v0.8.0 // indirect
- google.golang.org/protobuf v1.36.5 // indirect
+ golang.org/x/crypto v0.40.0 // indirect
+ golang.org/x/sys v0.34.0 // indirect
+ golang.org/x/time v0.12.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/cli/go.sum b/cli/go.sum
index 80c53abe..969eb804 100644
--- a/cli/go.sum
+++ b/cli/go.sum
@@ -1,4 +1,6 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op h1:+OSa/t11TFhqfrX0EOSqQBDJ0YlpmK0rDSiB19dg9M0=
+github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -11,14 +13,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
-github.com/dunglas/frankenphp v1.4.4 h1:NbAjn8XGwQQRXAENsyBB3dSv5c/364b43IyqkT/4Feg=
-github.com/dunglas/frankenphp v1.4.4/go.mod h1:y6H/Vp29TDz1TeGmx4z2sEJ02PsY3rpCvNJN/DQRQ5s=
-github.com/gammazero/deque v1.0.0 h1:LTmimT8H7bXkkCy6gZX7zNLtkbz4NdS2z8LZuor3j34=
-github.com/gammazero/deque v1.0.0/go.mod h1:iflpYvtGfM3U8S8j+sZEKIak3SAKYpA5/SQewgfXDKo=
-github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
-github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/dunglas/frankenphp v1.9.0 h1:tucI7uSZEmwGRGg7JxAf3wTwLrYs319mSc6fATG9z5I=
+github.com/dunglas/frankenphp v1.9.0/go.mod h1:jpmWK5Nmi2LkpgL+Td0+LQWRcQ5jVOYsuT9f+L7ohDs=
+github.com/gammazero/deque v1.1.0 h1:OyiyReBbnEG2PP0Bnv1AASLIYvyKqIFN5xfl1t8oGLo=
+github.com/gammazero/deque v1.1.0/go.mod h1:JVrR+Bj1NMQbPnYclvDlvSX0nVGReLrQZ0aUMuWLctg=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
+github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -26,12 +30,14 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/maypok86/otter v1.2.4 h1:HhW1Pq6VdJkmWwcZZq19BlEQkHtI8xgsQzBVXJU0nfc=
github.com/maypok86/otter v1.2.4/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
@@ -40,28 +46,28 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE=
-github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4=
-github.com/nats-io/nats-server/v2 v2.10.24 h1:KcqqQAD0ZZcG4yLxtvSFJY7CYKVYlnlWoAiVZ6i/IY4=
-github.com/nats-io/nats-server/v2 v2.10.24/go.mod h1:olvKt8E5ZlnjyqBGbAXtxvSQKsPodISK5Eo/euIta4s=
-github.com/nats-io/nats.go v1.38.0 h1:A7P+g7Wjp4/NWqDOOP/K6hfhr54DvdDQUznt5JFg9XA=
-github.com/nats-io/nats.go v1.38.0/go.mod h1:IGUM++TwokGnXPs82/wCuiHS02/aKrdYUQkU8If6yjw=
-github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0=
-github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE=
+github.com/nats-io/jwt/v2 v2.7.4 h1:jXFuDDxs/GQjGDZGhNgH4tXzSUK6WQi2rsj4xmsNOtI=
+github.com/nats-io/jwt/v2 v2.7.4/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
+github.com/nats-io/nats-server/v2 v2.11.6 h1:4VXRjbTUFKEB+7UoaKL3F5Y83xC7MxPoIONOnGgpkHw=
+github.com/nats-io/nats-server/v2 v2.11.6/go.mod h1:2xoztlcb4lDL5Blh1/BiukkKELXvKQ5Vy29FPVRBUYs=
+github.com/nats-io/nats.go v1.43.0 h1:uRFZ2FEoRvP64+UUhaTokyS18XBCR/xM2vQZKO4i8ug=
+github.com/nats-io/nats.go v1.43.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
+github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
+github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
-github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
-github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
-github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
-github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
+github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
@@ -83,19 +89,21 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
-golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
+go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
-golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
-google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
-google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/cli/glue/sanity.go b/cli/ids/id.go
similarity index 99%
rename from cli/glue/sanity.go
rename to cli/ids/id.go
index 43f4b04d..4247c7dd 100644
--- a/cli/glue/sanity.go
+++ b/cli/ids/id.go
@@ -1,4 +1,4 @@
-package glue
+package ids
import (
"fmt"
@@ -42,11 +42,6 @@ func (subj *Subject) Bucket() string {
// state ids
-type StateId struct {
- Id string
- Kind IdKind
-}
-
func fromEntityId(entity *EntityId) *StateId {
return &StateId{
Id: entity.String(),
@@ -177,3 +172,8 @@ func (id *OrchestrationId) String() string {
func (id *OrchestrationId) ToStateId() *StateId {
return fromOrchestrationId(id)
}
+
+type StateId struct {
+ Id string
+ Kind IdKind
+}
diff --git a/cli/init/template/tests/integrationTest.php b/cli/init/template/tests/integrationTest.php
index d11056f8..346e36d9 100644
--- a/cli/init/template/tests/integrationTest.php
+++ b/cli/init/template/tests/integrationTest.php
@@ -6,18 +6,20 @@
use Bottledcode\DurablePhp\State\Serializer;
use {{.Name}}\Entities\CountInterface;
use {{.Name}}\Orchestrations\Password;
+use function Bottledcode\DurablePhp\EntityId;
+use function Bottledcode\DurablePhp\OrchestrationInstance;
require_once __DIR__ . '/../vendor/autoload.php';
$client = DurableClient::get();
-$entity = new EntityId(CountInterface::class, random_int(0, 10000));
+$entity = EntityId(CountInterface::class, random_int(0, 10000));
echo "Signaling an entity, which will start an orchestration, which we will wait for completion\n";
$start = microtime(true);
$client->signal($entity, fn(CountInterface $state) => $state->countTo(100));
-$client->waitForCompletion(new OrchestrationInstance(\{{.Name}}\Orchestrations\Counter::class, $entity->id));
+$client->waitForCompletion(OrchestrationInstance(\{{.Name}}\Orchestrations\Counter::class, $entity->id));
$time = number_format(microtime(true) - $start, 2);
echo "Cool! That took $time seconds\n";
echo "Here's the state:\n" . json_encode($client->getEntitySnapshot($entity, CountInterface::class), JSON_PRETTY_PRINT) . "\n";
diff --git a/cli/lib/api.go b/cli/lib/api.go
index fc87db3c..440fa5b5 100644
--- a/cli/lib/api.go
+++ b/cli/lib/api.go
@@ -5,6 +5,7 @@ import (
"durable_php/auth"
"durable_php/config"
"durable_php/glue"
+ "durable_php/ids"
"encoding/json"
"fmt"
"github.com/dunglas/frankenphp"
@@ -101,7 +102,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
request, err := frankenphp.NewRequestWithContext(request, frankenphp.WithRequestEnv(map[string]string{
"LOG_LEVEL": "DEBUG",
- }), frankenphp.WithRequestLogger(logger))
+ }))
if err != nil {
logger.Error("Failed to serve request", zap.Error(err))
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
@@ -164,7 +165,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
logRequest(logger, request, ctx)
vars := mux.Vars(request)
- id := &glue.ActivityId{
+ id := &ids.ActivityId{
Id: vars["id"],
}
err := OutputStatus(ctx, writer, id.ToStateId(), js, logger)
@@ -255,11 +256,22 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
bootstrap := ctx.Value("bootstrap").(string)
- processReq := func(ctx context.Context, writer http.ResponseWriter, request *http.Request, id *glue.StateId, function glue.Method, headers http.Header) {
+ processReq := func(ctx context.Context, writer http.ResponseWriter, request *http.Request, id *ids.StateId, function glue.Method, headers http.Header) {
logger.Debug("Processing request to call function", zap.String("function", string(function)), zap.Any("Headers", headers))
ctx, cancel := context.WithCancel(context.WithValue(ctx, "bootstrap", bootstrap))
defer cancel()
+ rm := auth.GetResourceManager(ctx, js)
+ res, err := rm.DiscoverResource(ctx, id, logger, true)
+ if err != nil {
+ logger.Error("DiscoverResource", zap.Error(err))
+ panic(err)
+ }
+ if res != nil {
+ ac, _ := rm.ToAuthContext(ctx, res)
+ headers.Add("DPHP_AUTH_CONTEXT", string(ac))
+ }
+
msgs, stateFile, err, responseHeaders, deleteAfter := glue.FromApiRequest(ctx, request, function, logger, js, id, headers)
if err != nil {
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
@@ -315,6 +327,193 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
}
}
+ // PUT /entity/{name}/{id}/share/{userid}: share ownership of the resource with another user
+ r.HandleFunc("/entity/{name}/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) {
+ if stop := handleCors(writer, request); stop {
+ return
+ }
+
+ if request.Method != "PUT" {
+ http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := getCorrelationId(ctx, &request.Header, nil)
+ logRequest(logger, request, ctx)
+
+ vars := mux.Vars(request)
+ id := &ids.EntityId{
+ Name: strings.TrimSpace(vars["name"]),
+ Id: strings.TrimSpace(vars["id"]),
+ }
+ stateId := id.ToStateId()
+
+ // verify the user is authorized to access the resource
+ ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.Owner)
+ if done {
+ return
+ }
+
+ r, err := rm.DiscoverResource(ctx, stateId, logger, true)
+ if err != nil {
+ logger.Error("Failed to discover resource", zap.Error(err))
+ http.Error(writer, "Not Found", http.StatusNotFound)
+ return
+ }
+
+ newUser := strings.TrimSpace(vars["userid"])
+
+ err = r.ShareOwnership(auth.UserId(newUser), auth.GetUserFromContext(ctx), true)
+ if err != nil {
+ logger.Error("Failed to share ownership", zap.Error(err))
+ http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ err = r.Update(ctx, logger)
+ if err != nil {
+ logger.Error("Failed to update resource", zap.Error(err))
+ http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ logger.Info("Shared ownership", zap.String("id", id.String()), zap.String("newUser", newUser))
+ http.Error(writer, "", http.StatusOK)
+ })
+
+ // PUT /entity/{name}/{id}/grant/{user}/{operation}
+ r.HandleFunc("/entity/{name}/{id}/grant/{type}/{user}/{operation}", func(writer http.ResponseWriter, request *http.Request) {
+ if stop := handleCors(writer, request); stop {
+ return
+ }
+
+ if request.Method != "PUT" {
+ http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := getCorrelationId(ctx, &request.Header, nil)
+ logRequest(logger, request, ctx)
+
+ vars := mux.Vars(request)
+ id := &ids.EntityId{
+ Name: strings.TrimSpace(vars["name"]),
+ Id: strings.TrimSpace(vars["id"]),
+ }
+ stateId := id.ToStateId()
+
+ ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.SharePlus)
+ if done {
+ return
+ }
+
+ operation := auth.Owner
+ switch strings.ToLower(vars["operation"]) {
+ case "signal":
+ operation = auth.Signal
+ break
+ case "completion":
+ operation = auth.Completion
+ break
+ case "output":
+ operation = auth.Output
+ case "call":
+ operation = auth.Call
+ case "lock":
+ operation = auth.Lock
+ case "sharePlus":
+ operation = auth.SharePlus
+ case "shareMinus":
+ operation = auth.ShareMinus
+ default:
+ http.Error(writer, "", http.StatusBadRequest)
+ return
+ }
+
+ r, err := rm.DiscoverResource(ctx, stateId, logger, true)
+ if err != nil {
+ logger.Error("Failed to discover resource", zap.Error(err))
+ http.Error(writer, "", http.StatusNotFound)
+ return
+ }
+
+ switch vars["type"] {
+ case "user":
+ err = r.GrantUser(auth.UserId(vars["user"]), operation, ctx)
+ case "role":
+ err = r.GrantRole(auth.Role(vars["user"]), operation, ctx)
+ }
+ if err != nil {
+ logger.Error("Failed to grant resource", zap.Error(err))
+ http.Error(writer, "", http.StatusForbidden)
+ return
+ }
+
+ err = r.Update(ctx, logger)
+ if err != nil {
+ logger.Error("Failed to update resource", zap.Error(err))
+ http.Error(writer, "", http.StatusInternalServerError)
+ return
+ }
+
+ http.Error(writer, "", http.StatusOK)
+ })
+
+ // DELETE /entity/{name}/{id}/grant/{type}/{user}
+ r.HandleFunc("/entity/{name}/{id}/grant/{type}/{user}", func(writer http.ResponseWriter, request *http.Request) {
+ if stop := handleCors(writer, request); stop {
+ return
+ }
+
+ if request.Method != "DELETE" {
+ http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := getCorrelationId(ctx, &request.Header, nil)
+ logRequest(logger, request, ctx)
+
+ vars := mux.Vars(request)
+ id := &ids.EntityId{
+ Name: strings.TrimSpace(vars["name"]),
+ Id: strings.TrimSpace(vars["id"]),
+ }
+ stateId := id.ToStateId()
+
+ ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.ShareMinus)
+ if done {
+ return
+ }
+
+ r, err := rm.DiscoverResource(ctx, stateId, logger, true)
+ if err != nil {
+ logger.Error("Failed to discover resource", zap.Error(err))
+ http.Error(writer, "", http.StatusNotFound)
+ return
+ }
+
+ switch vars["type"] {
+ case "user":
+ err = r.RevokeUser(auth.UserId(vars["user"]), ctx)
+ case "role":
+ err = r.RevokeRole(auth.Role(vars["user"]), ctx)
+ }
+ if err != nil {
+ logger.Error("Failed to revoke resource", zap.Error(err))
+ http.Error(writer, "", http.StatusForbidden)
+ return
+ }
+
+ err = r.Update(ctx, logger)
+ if err != nil {
+ logger.Error("Failed to update resource", zap.Error(err))
+ http.Error(writer, "", http.StatusInternalServerError)
+ return
+ }
+
+ http.Error(writer, "", http.StatusOK)
+ })
+
// GET /entity/{name}/{id}
// get an entity state and status
// PUT /entity/{name}/{id}
@@ -327,7 +526,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
}
vars := mux.Vars(request)
- id := &glue.EntityId{
+ id := &ids.EntityId{
Name: strings.TrimSpace(vars["name"]),
Id: strings.TrimSpace(vars["id"]),
}
@@ -443,7 +642,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
return
}
- id := &glue.OrchestrationId{
+ id := &ids.OrchestrationId{
InstanceId: vars["name"],
ExecutionId: execId.String(),
}
@@ -456,6 +655,193 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
processReq(ctx, writer, request, id.ToStateId(), glue.StartOrchestration, make(http.Header))
})
+ // PUT /orchestration/{name}/{id}/share/{userid}: share ownership of the resource with another user
+ r.HandleFunc("/orchestration/{name}/{id}/share/{userid}", func(writer http.ResponseWriter, request *http.Request) {
+ if stop := handleCors(writer, request); stop {
+ return
+ }
+
+ if request.Method != "PUT" {
+ http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := getCorrelationId(ctx, &request.Header, nil)
+ logRequest(logger, request, ctx)
+
+ vars := mux.Vars(request)
+ id := &ids.OrchestrationId{
+ InstanceId: strings.TrimSpace(vars["name"]),
+ ExecutionId: strings.TrimSpace(vars["id"]),
+ }
+ stateId := id.ToStateId()
+
+ // verify the user is authorized to access the resource
+ ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.Owner)
+ if done {
+ return
+ }
+
+ r, err := rm.DiscoverResource(ctx, stateId, logger, true)
+ if err != nil {
+ logger.Error("Failed to discover resource", zap.Error(err))
+ http.Error(writer, "Not Found", http.StatusNotFound)
+ return
+ }
+
+ newUser := strings.TrimSpace(vars["userid"])
+
+ err = r.ShareOwnership(auth.UserId(newUser), auth.GetUserFromContext(ctx), true)
+ if err != nil {
+ logger.Error("Failed to share ownership", zap.Error(err))
+ http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ err = r.Update(ctx, logger)
+ if err != nil {
+ logger.Error("Failed to update resource", zap.Error(err))
+ http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ logger.Info("Shared ownership", zap.String("id", id.String()), zap.String("newUser", newUser))
+ http.Error(writer, "", http.StatusOK)
+ })
+
+ // PUT /orchestration/{name}/{id}/grant/{user}/{operation}
+ r.HandleFunc("/orchestration/{name}/{id}/grant/{type}/{user}/{operation}", func(writer http.ResponseWriter, request *http.Request) {
+ if stop := handleCors(writer, request); stop {
+ return
+ }
+
+ if request.Method != "PUT" {
+ http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := getCorrelationId(ctx, &request.Header, nil)
+ logRequest(logger, request, ctx)
+
+ vars := mux.Vars(request)
+ id := &ids.OrchestrationId{
+ InstanceId: strings.TrimSpace(vars["name"]),
+ ExecutionId: strings.TrimSpace(vars["id"]),
+ }
+ stateId := id.ToStateId()
+
+ ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.SharePlus)
+ if done {
+ return
+ }
+
+ operation := auth.Owner
+ switch strings.ToLower(vars["operation"]) {
+ case "signal":
+ operation = auth.Signal
+ break
+ case "completion":
+ operation = auth.Completion
+ break
+ case "output":
+ operation = auth.Output
+ case "call":
+ operation = auth.Call
+ case "lock":
+ operation = auth.Lock
+ case "sharePlus":
+ operation = auth.SharePlus
+ case "shareMinus":
+ operation = auth.ShareMinus
+ default:
+ http.Error(writer, "", http.StatusBadRequest)
+ return
+ }
+
+ r, err := rm.DiscoverResource(ctx, stateId, logger, true)
+ if err != nil {
+ logger.Error("Failed to discover resource", zap.Error(err))
+ http.Error(writer, "", http.StatusNotFound)
+ return
+ }
+
+ switch vars["type"] {
+ case "user":
+ err = r.GrantUser(auth.UserId(vars["user"]), operation, ctx)
+ case "role":
+ err = r.GrantRole(auth.Role(vars["user"]), operation, ctx)
+ }
+ if err != nil {
+ logger.Error("Failed to grant resource", zap.Error(err))
+ http.Error(writer, "", http.StatusForbidden)
+ return
+ }
+
+ err = r.Update(ctx, logger)
+ if err != nil {
+ logger.Error("Failed to update resource", zap.Error(err))
+ http.Error(writer, "", http.StatusInternalServerError)
+ return
+ }
+
+ http.Error(writer, "", http.StatusOK)
+ })
+
+ // DELETE /orchestration/{name}/{id}/grant/{type}/{user}
+ r.HandleFunc("/orchestration/{name}/{id}/grant/{type}/{user}", func(writer http.ResponseWriter, request *http.Request) {
+ if stop := handleCors(writer, request); stop {
+ return
+ }
+
+ if request.Method != "DELETE" {
+ http.Error(writer, "Method Not Allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ ctx := getCorrelationId(ctx, &request.Header, nil)
+ logRequest(logger, request, ctx)
+
+ vars := mux.Vars(request)
+ id := &ids.OrchestrationId{
+ InstanceId: strings.TrimSpace(vars["name"]),
+ ExecutionId: strings.TrimSpace(vars["id"]),
+ }
+ stateId := id.ToStateId()
+
+ ctx, done := authorize(writer, request, config, ctx, rm, stateId, logger, true, auth.ShareMinus)
+ if done {
+ return
+ }
+
+ r, err := rm.DiscoverResource(ctx, stateId, logger, true)
+ if err != nil {
+ logger.Error("Failed to discover resource", zap.Error(err))
+ http.Error(writer, "", http.StatusNotFound)
+ return
+ }
+
+ switch vars["type"] {
+ case "user":
+ err = r.RevokeUser(auth.UserId(vars["user"]), ctx)
+ case "role":
+ err = r.RevokeRole(auth.Role(vars["user"]), ctx)
+ }
+ if err != nil {
+ logger.Error("Failed to revoke resource", zap.Error(err))
+ http.Error(writer, "", http.StatusForbidden)
+ return
+ }
+
+ err = r.Update(ctx, logger)
+ if err != nil {
+ logger.Error("Failed to update resource", zap.Error(err))
+ http.Error(writer, "", http.StatusInternalServerError)
+ return
+ }
+
+ http.Error(writer, "", http.StatusOK)
+ })
+
// PUT /orchestration/{name}/{id}
// start a new orchestration
// GET /orchestration/{name}/{id}?wait=??
@@ -471,7 +857,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
ctx := getCorrelationId(ctx, &request.Header, nil)
logRequest(logger, request, ctx)
- id := &glue.OrchestrationId{
+ id := &ids.OrchestrationId{
InstanceId: strings.TrimSpace(vars["name"]),
ExecutionId: strings.TrimSpace(vars["id"]),
}
@@ -537,7 +923,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
defer cancel()
bucket, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
- Bucket: string(glue.Orchestration),
+ Bucket: string(ids.Orchestration),
Description: "Holds orchestration state and history",
Compression: true,
})
@@ -560,7 +946,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
logger.Debug("Got change!")
status, err := extractStatus(update.Value())
if err != nil {
- http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
+ http.Error(writer, "\"Internal Server Error\"", http.StatusInternalServerError)
return
}
if runtimeStatus, ok := status.(map[string]interface{})["runtimeStatus"].(string); ok {
@@ -602,7 +988,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
logRequest(logger, request, ctx)
vars := mux.Vars(request)
- id := &glue.OrchestrationId{
+ id := &ids.OrchestrationId{
InstanceId: vars["name"],
ExecutionId: vars["id"],
}
@@ -620,7 +1006,7 @@ func Startup(ctx context.Context, js jetstream.JetStream, logger *zap.Logger, po
})
r.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
- logger.Warn("Unkown endpoint")
+ logger.Warn("Unknown endpoint")
ctx := getCorrelationId(ctx, &request.Header, nil)
logRequest(logger, request, ctx)
})
@@ -634,7 +1020,7 @@ func authorize(
config *config.Config,
ctx context.Context,
rm *auth.ResourceManager,
- id *glue.StateId,
+ id *ids.StateId,
logger *zap.Logger,
preventCreation bool,
operation auth.Operation,
@@ -648,7 +1034,7 @@ func authorize(
}
resource, err := rm.DiscoverResource(ctx, id, logger, preventCreation)
if err != nil {
- logger.Warn("User attempted to create new resource not authorized to create", zap.Any("id", id.String()), zap.Error(err))
+ logger.Warn("User attempted to create new resource not authorized to create", zap.Any("id", id.String()), zap.Any("user", auth.GetUserFromContext(ctx)), zap.Error(err))
http.Error(writer, "Not Authorized", http.StatusForbidden)
return nil, true
}
@@ -704,7 +1090,7 @@ func OutputList(writer http.ResponseWriter, store jetstream.ObjectStore) {
continue
}
- id := glue.ParseStateId(activity.Headers.Get(string(glue.HeaderStateId)))
+ id := ids.ParseStateId(activity.Headers.Get(string(glue.HeaderStateId)))
t := id.String()
parts := strings.Split(t, ":")[1:]
names = append(names, parts)
@@ -716,7 +1102,7 @@ func OutputList(writer http.ResponseWriter, store jetstream.ObjectStore) {
}
}
-func OutputStatus(ctx context.Context, writer http.ResponseWriter, id *glue.StateId, stream jetstream.JetStream, logger *zap.Logger) error {
+func OutputStatus(ctx context.Context, writer http.ResponseWriter, id *ids.StateId, stream jetstream.JetStream, logger *zap.Logger) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
stateFile, _ := glue.GetStateFile(id, stream, ctx, logger)
diff --git a/cli/lib/billing.go b/cli/lib/billing.go
index 4734bb57..c19a3422 100644
--- a/cli/lib/billing.go
+++ b/cli/lib/billing.go
@@ -4,6 +4,7 @@ import (
"context"
"durable_php/config"
"durable_php/glue"
+ "durable_php/ids"
"encoding/json"
"fmt"
"github.com/nats-io/nats.go/jetstream"
@@ -42,7 +43,7 @@ func StartBillingProcessor(ctx context.Context, config *config.Config, js jetstr
return err
}
- maybeSendActivityBilling := func(id *glue.StateId) {
+ maybeSendActivityBilling := func(id *ids.StateId) {
started, err := activityTracker.Get(ctx, id.ToSubject().String()+"_start")
if err != nil {
return
@@ -87,9 +88,9 @@ func StartBillingProcessor(ctx context.Context, config *config.Config, js jetstr
consume, err := consumer.Consume(func(msg jetstream.Msg) {
targetType := msg.Headers().Get(string(glue.HeaderTargetType))
eventType := msg.Headers().Get(string(glue.HeaderEventType))
- id := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId)))
+ id := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId)))
nowBytes := []byte(msg.Headers().Get(string(glue.HeaderEmittedAt)))
- emittedBy := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy)))
+ emittedBy := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy)))
switch targetType {
case "Activity":
diff --git a/cli/lib/consumer.go b/cli/lib/consumer.go
index 142e16ce..8adeddc2 100644
--- a/cli/lib/consumer.go
+++ b/cli/lib/consumer.go
@@ -6,6 +6,7 @@ import (
"durable_php/auth"
"durable_php/config"
"durable_php/glue"
+ "durable_php/ids"
"encoding/json"
"fmt"
"github.com/nats-io/nats.go/jetstream"
@@ -16,7 +17,7 @@ import (
"time"
)
-func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config.Config, kind glue.IdKind, logger *zap.Logger, js jetstream.JetStream, rm *auth.ResourceManager) {
+func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config.Config, kind ids.IdKind, logger *zap.Logger, js jetstream.JetStream, rm *auth.ResourceManager) {
logger.Debug("Creating consumer", zap.String("stream", config.Stream), zap.String("kind", string(kind)))
consumer, err := stream.Consumer(ctx, config.Stream+"-"+string(kind))
@@ -24,7 +25,7 @@ func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config.
panic(err)
}
- iter, err := consumer.Messages(jetstream.PullMaxMessages(1))
+ iter, err := consumer.Messages(jetstream.PullMaxMessages(1), jetstream.WithMessagesErrOnMissingHeartbeat(false))
if err != nil {
panic(err)
}
@@ -55,7 +56,7 @@ func BuildConsumer(stream jetstream.Stream, ctx context.Context, config *config.
}
if strings.HasSuffix(msg.Subject(), ".delete") {
- id := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId)))
+ id := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId)))
err := glue.DeleteState(ctx, js, logger, id)
if err != nil {
panic(err)
@@ -98,8 +99,8 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j
logger.Debug("Received message", zap.Any("msg", msg))
// lock the Subject, if it is a lockable Subject
- id := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId)))
- if id.Kind == glue.Entity {
+ id := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderStateId)))
+ if id.Kind == ids.Entity {
unlocker, err := lockSubject(ctx, id.ToSubject(), js, logger)
if err != nil {
return err
@@ -128,7 +129,7 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j
// extract the source operations
sourceOps := strings.Split(msg.Headers().Get(string(glue.HeaderSourceOps)), ",")
// retrieve the source
- sourceId := glue.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy)))
+ sourceId := ids.ParseStateId(msg.Headers().Get(string(glue.HeaderEmittedBy)))
if sourceR, err := rm.DiscoverResource(ctx, sourceId, logger, true); err != nil {
if sourceR == nil {
logger.Warn("User accessed missing object", zap.Any("operation", sourceOps), zap.String("from", sourceId.Id), zap.String("to", id.Id), zap.String("user", string(currentUser.UserId)))
@@ -291,6 +292,16 @@ func processMsg(ctx context.Context, logger *zap.Logger, msg jetstream.Msg, js j
env["STATE_ID"] = msg.Headers().Get(string(glue.HeaderStateId))
env["REMOTE_ADDR"] = msg.Headers().Get("Remote-Addr")
+ res, err := rm.DiscoverResource(ctx, id, logger, true)
+ if err != nil {
+ logger.Error("DiscoverResource", zap.Error(err))
+ panic(err)
+ }
+ if res != nil {
+ ac, _ := rm.ToAuthContext(ctx, res)
+ headers.Add("DPHP_AUTH_CONTEXT", string(ac))
+ }
+
msgs, headers, _, deleteAfter := glu.Execute(ctx, headers, logger, env, js, id)
// now update the stored state, if this fails due to optimistic concurrency, we immediately nak and fail
diff --git a/cli/lib/indexer.go b/cli/lib/indexer.go
index 3087083c..7cfe0b3f 100644
--- a/cli/lib/indexer.go
+++ b/cli/lib/indexer.go
@@ -4,6 +4,7 @@ import (
"context"
"durable_php/config"
"durable_php/glue"
+ "durable_php/ids"
"encoding/json"
"github.com/nats-io/nats.go/jetstream"
"github.com/typesense/typesense-go/typesense"
@@ -15,13 +16,13 @@ import (
"time"
)
-func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind, js jetstream.JetStream, logger *zap.Logger) error {
+func IndexerListen(ctx context.Context, config *config.Config, kind ids.IdKind, js jetstream.JetStream, logger *zap.Logger) error {
//logger.Info("Starting indexer extension", zap.String("for", string(kind)), zap.Any("config", config.Extensions.Search))
client := typesense.NewClient(typesense.WithServer(config.Extensions.Search.Url), typesense.WithAPIKey(config.Extensions.Search.Key))
switch kind {
- case glue.Entity:
+ case ids.Entity:
collection := client.Collection(config.Stream + "_entities")
err := CreateEntityIndex(ctx, client, config)
@@ -99,7 +100,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind,
go func() {
ctx, done := context.WithCancel(ctx)
- obj, err := glue.GetObjectStore(glue.Entity, js, ctx)
+ obj, err := glue.GetObjectStore(ids.Entity, js, ctx)
if err != nil {
logger.Warn("Unable to load state for entity", zap.Error(err))
done()
@@ -119,7 +120,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind,
done()
return
}
- id := glue.ParseStateId(result["id"].(map[string]interface{})["id"].(string))
+ id := ids.ParseStateId(result["id"].(map[string]interface{})["id"].(string))
eid, _ := id.ToEntityId()
entityData := struct {
@@ -140,7 +141,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind,
}()
}
}()
- case glue.Orchestration:
+ case ids.Orchestration:
collection := client.Collection(config.Stream + "_orchestrations")
err := CreateOrchestrationIndex(ctx, client, config)
@@ -148,7 +149,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind,
return err
}
- obj, err := js.KeyValue(ctx, string(glue.Orchestration))
+ obj, err := js.KeyValue(ctx, string(ids.Orchestration))
if err != nil {
// key value doesn't exist yet, try again in a few minutes
go func() {
@@ -226,7 +227,7 @@ func IndexerListen(ctx context.Context, config *config.Config, kind glue.IdKind,
return
}
- id := glue.ParseStateId(result["id"].(map[string]interface{})["id"].(string))
+ id := ids.ParseStateId(result["id"].(map[string]interface{})["id"].(string))
oid, _ := id.ToOrchestrationId()
status := result["status"].(map[string]interface{})
diff --git a/cli/lib/locks.go b/cli/lib/locks.go
index c251588c..2b72b41f 100644
--- a/cli/lib/locks.go
+++ b/cli/lib/locks.go
@@ -2,7 +2,7 @@ package lib
import (
"context"
- "durable_php/glue"
+ "durable_php/ids"
"errors"
"github.com/nats-io/nats.go/jetstream"
"go.uber.org/zap"
@@ -14,7 +14,7 @@ const (
LockKey string = "lock"
)
-func acquireLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyValue, logger *zap.Logger) (bool, uint64) {
+func acquireLock(ctx context.Context, subject *ids.Subject, kv jetstream.KeyValue, logger *zap.Logger) (bool, uint64) {
value, err := kv.Get(ctx, LockKey)
// not found or empty value
if err != nil || string(value.Value()) == "" {
@@ -31,7 +31,7 @@ func acquireLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyVal
return false, value.Revision()
}
-func waitForLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyValue, logger *zap.Logger) bool {
+func waitForLock(ctx context.Context, subject *ids.Subject, kv jetstream.KeyValue, logger *zap.Logger) bool {
logger.Debug("Waiting for lock", zap.String("Subject", subject.String()))
ok, revision := acquireLock(ctx, subject, kv, logger)
@@ -61,7 +61,7 @@ func waitForLock(ctx context.Context, subject *glue.Subject, kv jetstream.KeyVal
}
}
-func lockSubject(ctx context.Context, subject *glue.Subject, js jetstream.JetStream, logger *zap.Logger) (func() error, error) {
+func lockSubject(ctx context.Context, subject *ids.Subject, js jetstream.JetStream, logger *zap.Logger) (func() error, error) {
logger.Debug("Attempting to take lock", zap.String("Subject", subject.String()))
kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: subject.Bucket(),
diff --git a/composer.json b/composer.json
index 3ebee4a1..bdc81d84 100644
--- a/composer.json
+++ b/composer.json
@@ -8,7 +8,11 @@
"autoload": {
"psr-4": {
"Bottledcode\\DurablePhp\\": "src/"
- }
+ },
+ "files": [
+ "src/functions.php",
+ "src/Contexts/AuthContext/functions.php"
+ ]
},
"autoload-dev": {
"psr-4": {
@@ -26,24 +30,25 @@
"license": "MIT",
"name": "bottledcode/durable-php",
"require": {
- "adhocore/cli": "^1.7.1",
- "amphp/file": "^3.1.1",
- "amphp/http-client": "^5.1.0",
+ "adhocore/cli": "^1.9.4",
+ "amphp/file": "^3.2.0",
+ "amphp/http-client": "^5.3.3",
"amphp/log": "^v2.0.0",
- "amphp/parallel": "^2.2.9",
- "crell/serde": "^1.2.0",
+ "amphp/parallel": "^2.3.1",
+ "crell/serde": "^1.5.0",
"nesbot/carbon": ">2.0",
- "php": ">=8.3",
- "php-di/php-di": "^7.0.7",
- "ramsey/uuid": "^4.7.6",
- "webonyx/graphql-php": "^15.12.5",
- "withinboredom/time": "^5.0.0",
- "nikic/php-parser": "^5.1"
+ "nikic/php-parser": "^5.6",
+ "php": ">=8.4",
+ "php-di/php-di": "^7.0.11",
+ "ramsey/uuid": "^4.9.0",
+ "webonyx/graphql-php": "^15.22.0",
+ "withinboredom/records": "^0.1.3",
+ "withinboredom/time": "^6.0.0"
},
"require-dev": {
- "laravel/pint": "^1.17.2",
+ "laravel/pint": "^1.24.0",
"mockery/mockery": "^1.6.12",
- "pestphp/pest": "^2.35.1 || ^3.0.0"
+ "pestphp/pest": "^2.35.1 || ^3.8.2"
},
"scripts": {
"test": "pest"
diff --git a/docs/orchestrations.md b/docs/orchestrations.md
index d817827b..95c24a97 100644
--- a/docs/orchestrations.md
+++ b/docs/orchestrations.md
@@ -132,7 +132,7 @@ Here's an example showing a lock:
```php
function orch(\Bottledcode\DurablePhp\OrchestrationContext $context): void {
- $entityId = new \Bottledcode\DurablePhp\State\EntityId('myEntity', 'id');
+ $entityId = \Bottledcode\DurablePhp\EntityId('myEntity', 'id');
$lock = $context->lockEntity($entityId);
// from here-on, you have exclusive access to the entity
$context->entityOp($entityId, fn($entity) => $entity->add(5));
diff --git a/src/Contexts/AuthContext.php b/src/Contexts/AuthContext.php
new file mode 100644
index 00000000..de8c9478
--- /dev/null
+++ b/src/Contexts/AuthContext.php
@@ -0,0 +1,38 @@
+
+ */
+ #[SequenceField(arrayType: Owner::class)]
+ public array $owners;
+
+ /**
+ * @var array
+ */
+ #[SequenceField(arrayType: Share::class)]
+ public array $shares;
+
+ public static function fromCurrentContext(): ?AuthContext
+ {
+ if (isset($_SERVER['HTTP_DPHP_AUTH_CONTEXT'])) {
+ $json = json_decode($_SERVER['HTTP_DPHP_AUTH_CONTEXT'], true, flags: JSON_THROW_ON_ERROR);
+
+ return Serializer::deserialize($json, self::class);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Contexts/AuthContext/Share.php b/src/Contexts/AuthContext/Share.php
new file mode 100644
index 00000000..54dfd993
--- /dev/null
+++ b/src/Contexts/AuthContext/Share.php
@@ -0,0 +1,27 @@
+ Owner::class,
+ 'role' => Role::class,
+ 'user' => User::class,
+])]
+abstract readonly class Share extends Record
+{
+ public string $subject;
+
+ /**
+ * @var array
+ */
+ #[SequenceField(arrayType: Operation::class)]
+ public array $allowed;
+}
diff --git a/src/Contexts/AuthContext/Share/Owner.php b/src/Contexts/AuthContext/Share/Owner.php
new file mode 100644
index 00000000..3b3ec9df
--- /dev/null
+++ b/src/Contexts/AuthContext/Share/Owner.php
@@ -0,0 +1,29 @@
+getMethod('fromArgs')->invoke(null, subject: $subject, allowed: [Operation::Owner]);
+}
+
+function Role(string $subject, Operation ...$allowed): Role
+{
+ $ref = new ReflectionClass(Role::class);
+
+ return $ref->getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed);
+}
+
+function User(string $subject, Operation ...$allowed): User
+{
+ $ref = new ReflectionClass(User::class);
+
+ return $ref->getMethod('fromArgs')->invoke(null, subject: $subject, allowed: $allowed);
+}
diff --git a/src/DurableClient.php b/src/DurableClient.php
index 61ada64f..df594a24 100644
--- a/src/DurableClient.php
+++ b/src/DurableClient.php
@@ -1,4 +1,5 @@
entityClient->deleteEntity($entityId);
}
+
+ public function shareEntityOwnership(EntityId $id, string $with): void
+ {
+ $this->entityClient->shareEntityOwnership($id, $with);
+ }
+
+ public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void
+ {
+ $this->entityClient->grantEntityAccessToUser($id, $user, $operation);
+ }
+
+ public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void
+ {
+ $this->entityClient->grantEntityAccessToRole($id, $role, $operation);
+ }
+
+ public function revokeEntityAccessToUser(EntityId $id, string $user): void
+ {
+ $this->entityClient->revokeEntityAccessToUser($id, $user);
+ }
+
+ public function revokeEntityAccessToRole(EntityId $id, string $role): void
+ {
+ $this->entityClient->revokeEntityAccessToRole($id, $role);
+ }
+
+ public function shareOrchestrationOwnership(OrchestrationInstance $id, string $with): void
+ {
+ $this->orchestrationClient->shareOrchestrationOwnership($id, $with);
+ }
+
+ public function grantOrchestrationAccessToUser(OrchestrationInstance $id, string $user, Operation $operation): void
+ {
+ $this->orchestrationClient->grantOrchestrationAccessToUser($id, $user, $operation);
+ }
+
+ public function grantOrchestrationAccessToRole(OrchestrationInstance $id, string $role, Operation $operation): void
+ {
+ $this->orchestrationClient->grantOrchestrationAccessToRole($id, $role, $operation);
+ }
+
+ public function revokeOrchestrationAccessToUser(OrchestrationInstance $id, string $user): void
+ {
+ $this->orchestrationClient->revokeOrchestrationAccessToUser($id, $user);
+ }
+
+ public function revokeOrchestrationAccessToRole(OrchestrationInstance $id, string $role): void
+ {
+ $this->orchestrationClient->revokeOrchestrationAccessToRole($id, $role);
+ }
}
diff --git a/src/EntityClientInterface.php b/src/EntityClientInterface.php
index 52eaf7c1..e2e6e5b2 100644
--- a/src/EntityClientInterface.php
+++ b/src/EntityClientInterface.php
@@ -24,6 +24,7 @@
namespace Bottledcode\DurablePhp;
+use Bottledcode\DurablePhp\Events\Shares\Operation;
use Bottledcode\DurablePhp\Search\EntityFilter;
use Bottledcode\DurablePhp\State\EntityId;
use Bottledcode\DurablePhp\State\EntityState;
@@ -80,4 +81,14 @@ public function getEntitySnapshot(EntityId $entityId): ?EntityState;
* Deletes an entity
*/
public function deleteEntity(EntityId $entityId): void;
+
+ public function shareEntityOwnership(EntityId $id, string $with): void;
+
+ public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void;
+
+ public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void;
+
+ public function revokeEntityAccessToUser(EntityId $id, string $user): void;
+
+ public function revokeEntityAccessToRole(EntityId $id, string $role): void;
}
diff --git a/src/EntityContext.php b/src/EntityContext.php
index 49991dd2..fae80d18 100644
--- a/src/EntityContext.php
+++ b/src/EntityContext.php
@@ -24,7 +24,14 @@
namespace Bottledcode\DurablePhp;
+use Bottledcode\DurablePhp\Events\GiveOwnership;
use Bottledcode\DurablePhp\Events\RaiseEvent;
+use Bottledcode\DurablePhp\Events\RevokeRole;
+use Bottledcode\DurablePhp\Events\RevokeUser;
+use Bottledcode\DurablePhp\Events\ShareOwnership;
+use Bottledcode\DurablePhp\Events\Shares\Operation;
+use Bottledcode\DurablePhp\Events\ShareWithRole;
+use Bottledcode\DurablePhp\Events\ShareWithUser;
use Bottledcode\DurablePhp\Events\StartExecution;
use Bottledcode\DurablePhp\Events\TaskCompleted;
use Bottledcode\DurablePhp\Events\WithDelay;
@@ -36,7 +43,6 @@
use Bottledcode\DurablePhp\State\EntityHistory;
use Bottledcode\DurablePhp\State\EntityId;
use Bottledcode\DurablePhp\State\Ids\StateId;
-use Bottledcode\DurablePhp\State\OrchestrationInstance;
use Closure;
use Crell\Serde\Attributes\ClassSettings;
use DateTimeImmutable;
@@ -132,7 +138,7 @@ public function startNewOrchestration(string $orchestration, array $input = [],
$id = Uuid::uuid7()->toString();
}
- $instance = StateId::fromInstance(new OrchestrationInstance($orchestration, $id));
+ $instance = StateId::fromInstance(OrchestrationInstance($orchestration, $id));
$this->eventDispatcher->fire(
WithOrchestration::forInstance(
$instance,
@@ -189,4 +195,64 @@ public function currentUserId(): string
{
return $this->user->userId;
}
+
+ public function shareOwnership(string $withUser): void
+ {
+ $this->eventDispatcher->fire(
+ WithEntity::forInstance(
+ StateId::fromEntityId($this->id),
+ ShareOwnership::withUser($withUser),
+ ),
+ );
+ }
+
+ public function giveOwnership(string $withUser): void
+ {
+ $this->eventDispatcher->fire(
+ WithEntity::forInstance(
+ StateId::fromEntityId($this->id),
+ GiveOwnership::withUser($withUser),
+ ),
+ );
+ }
+
+ public function grantUser(string $withUser, Operation ...$operation): void
+ {
+ $this->eventDispatcher->fire(
+ WithEntity::forInstance(
+ StateId::fromEntityId($this->id),
+ ShareWithUser::For($withUser, ...$operation),
+ ),
+ );
+ }
+
+ public function grantRole(string $withRole, Operation ...$operation): void
+ {
+ $this->eventDispatcher->fire(
+ WithEntity::forInstance(
+ StateId::fromEntityId($this->id),
+ ShareWithRole::For($withRole, ...$operation),
+ ),
+ );
+ }
+
+ public function revokeUser(string $user): void
+ {
+ $this->eventDispatcher->fire(
+ WithEntity::forInstance(
+ StateId::fromEntityId($this->id),
+ RevokeUser::completely($user),
+ ),
+ );
+ }
+
+ public function revokeRole(string $role): void
+ {
+ $this->eventDispatcher->fire(
+ WithEntity::forInstance(
+ StateId::fromEntityId($this->id),
+ RevokeRole::completely($role),
+ ),
+ );
+ }
}
diff --git a/src/EntityContextInterface.php b/src/EntityContextInterface.php
index 873c9682..adc2683a 100644
--- a/src/EntityContextInterface.php
+++ b/src/EntityContextInterface.php
@@ -24,6 +24,7 @@
namespace Bottledcode\DurablePhp;
+use Bottledcode\DurablePhp\Events\Shares\Operation;
use Bottledcode\DurablePhp\State\EntityId;
use Closure;
use Crell\Serde\Attributes\ClassNameTypeMap;
@@ -112,4 +113,16 @@ public function delayUntil(
public function delay(Closure $self, DateTimeInterface $until = new DateTimeImmutable()): void;
public function currentUserId(): string;
+
+ public function shareOwnership(string $withUser): void;
+
+ public function grantUser(string $withUser, Operation ...$operation): void;
+
+ public function grantRole(string $withRole, Operation ...$operation): void;
+
+ public function revokeUser(string $user): void;
+
+ public function revokeRole(string $role): void;
+
+ public function giveOwnership(string $withUser): void;
}
diff --git a/src/Events/EventDescription.php b/src/Events/EventDescription.php
index 37e3630d..44ad60f0 100644
--- a/src/Events/EventDescription.php
+++ b/src/Events/EventDescription.php
@@ -1,4 +1,5 @@
replyTo = $event->getReplyTo();
}
@@ -93,14 +96,14 @@ private function describe(Event $event): void
$this->meta = Serializer::serialize($event);
}
- $reflection = new \ReflectionClass($event);
- foreach($reflection->getAttributes(NeedsTarget::class) as $target) {
+ $reflection = new ReflectionClass($event);
+ foreach ($reflection->getAttributes(NeedsTarget::class) as $target) {
/** @var NeedsTarget $attr */
$attr = $target->newInstance();
$targetOps[] = $attr->operation;
}
- foreach($reflection->getAttributes(NeedsSource::class) as $target) {
+ foreach ($reflection->getAttributes(NeedsSource::class) as $target) {
/** @var NeedsTarget $attr */
$attr = $target->newInstance();
$sourceOps[] = $attr->operation;
@@ -109,14 +112,24 @@ private function describe(Event $event): void
$event = $event->getInnerEvent();
}
- $reflection = new \ReflectionClass($event);
- foreach($reflection->getAttributes(NeedsTarget::class) as $target) {
+ if ($event instanceof PoisonPill) {
+ $this->isPoisoned = true;
+ }
+ if ($event instanceof External) {
+ $this->meta = Serializer::serialize($event);
+ }
+ if ($event instanceof ReplyToInterface) {
+ $this->replyTo = $event->getReplyTo();
+ }
+
+ $reflection = new ReflectionClass($event);
+ foreach ($reflection->getAttributes(NeedsTarget::class) as $target) {
/** @var NeedsTarget $attr */
$attr = $target->newInstance();
$targetOps[] = $attr->operation;
}
- foreach($reflection->getAttributes(NeedsSource::class) as $target) {
+ foreach ($reflection->getAttributes(NeedsSource::class) as $target) {
/** @var NeedsTarget $attr */
$attr = $target->newInstance();
$sourceOps[] = $attr->operation;
@@ -131,11 +144,12 @@ private function describe(Event $event): void
$this->replyTo ??= null;
$this->scheduledAt ??= null;
$this->destination ??= null;
+ $this->meta ??= [];
$this->targetType = match (true) {
- $this->destination->isActivityId() => TargetType::Activity,
- $this->destination->isOrchestrationId() => TargetType::Orchestration,
- $this->destination->isEntityId() => TargetType::Entity,
+ $this->destination?->isActivityId() => TargetType::Activity,
+ $this->destination?->isOrchestrationId() => TargetType::Orchestration,
+ $this->destination?->isEntityId() => TargetType::Entity,
default => TargetType::None,
};
}
@@ -145,27 +159,33 @@ public static function fromStream(string $data): self
$data = base64_decode($data, true);
$data = function_exists('gzdecode') ? gzdecode($data) : $data;
$data = function_exists('igbinary_unserialize') ? igbinary_unserialize($data) : unserialize($data);
+ $data = Serializer::deserialize($data, Event::class);
return new self($data);
}
/**
- * @throws \JsonException
+ * @throws JsonException
*/
public static function fromJson(string $json): EventDescription
{
- return new EventDescription(Serializer::deserialize(json_decode($json, true, 512, JSON_THROW_ON_ERROR), Event::class));
+ return new EventDescription(
+ Serializer::deserialize(json_decode($json, true, 512, JSON_THROW_ON_ERROR), Event::class),
+ );
}
public function toStream(): string
{
- $serialized = function_exists('igbinary_serialize') ? igbinary_serialize($this->event) : serialize($this->event);
+ $serialized = Serializer::serialize($this->event);
+
+ $serialized =
+ function_exists('igbinary_serialize') ? igbinary_serialize($serialized) : serialize($serialized);
$serialized = function_exists('gzencode') ? gzencode($serialized) : $serialized;
$event = base64_encode($serialized);
return json_encode([
- 'destination' => $this->destination->id,
+ 'destination' => $this->destination?->id ?? null,
'replyTo' => $this->replyTo?->id ?? '',
'scheduleAt' => $this->scheduledAt?->format(DATE_ATOM) ?? gmdate(DATE_ATOM, time() - 30),
'eventId' => $this->eventId,
@@ -179,7 +199,7 @@ public function toStream(): string
}
/**
- * @throws \JsonException
+ * @throws JsonException
*/
public function toJson(): string
{
diff --git a/src/Events/EventQueue.php b/src/Events/EventQueue.php
index 8ab8d867..48cfd506 100644
--- a/src/Events/EventQueue.php
+++ b/src/Events/EventQueue.php
@@ -28,9 +28,8 @@
use DateTimeImmutable;
use Revolt\EventLoop;
use SplQueue;
-use Withinboredom\Time\Seconds;
-use Withinboredom\Time\Time;
-use Withinboredom\Time\TimeUnit;
+use Withinboredom\Time;
+use Withinboredom\Time\Unit;
use function Withinboredom\Time\Seconds;
@@ -101,8 +100,8 @@ public function getNext(array $requeueKeys): Event|null
public function enqueue(string $key, Event $event): void
{
$delay = $this->getDelay($event);
- if ($delay->as(TimeUnit::Seconds) > 0) {
- EventLoop::delay($delay->as(TimeUnit::Seconds), function () use ($key, $event): void {
+ if ($delay->as(Unit::Seconds) > 0) {
+ EventLoop::delay($delay->as(Unit::Seconds), function () use ($key, $event): void {
$this->enqueue($key, $event);
if ($this->cancellation !== null) {
$this->cancellation?->cancel();
@@ -134,7 +133,7 @@ private function getDelay(Event $event): Time
$event = $event->getInnerEvent();
}
- return new Seconds(0);
+ return Seconds(0);
}
private function addKey(string $key): void
diff --git a/src/Events/RevokeRole.php b/src/Events/RevokeRole.php
index bba60c81..04757628 100644
--- a/src/Events/RevokeRole.php
+++ b/src/Events/RevokeRole.php
@@ -1,4 +1,5 @@
role, );
+ return sprintf('Revoke(role: %s)', $this->role);
}
}
diff --git a/src/Events/RevokeUser.php b/src/Events/RevokeUser.php
index df357a51..b3032f97 100644
--- a/src/Events/RevokeUser.php
+++ b/src/Events/RevokeUser.php
@@ -1,4 +1,5 @@
$participants
- * @param Event $innerEvent
+ * @param array $participants
*/
public function __construct(
string $eventId,
@@ -46,7 +43,7 @@ public function __construct(
public array $participants,
public Event $innerEvent,
) {
- parent::__construct($this->innerEvent ?: Uuid::uuid7());
+ parent::__construct($this->innerEvent->eventId ?: Uuid::uuid7());
}
public static function onEntity(StateId $owner, Event $innerEvent, StateId ...$targets): self
diff --git a/src/Gateway/Graph/index.php b/src/Gateway/Graph/index.php
index 2c3f3dc7..102a4f81 100644
--- a/src/Gateway/Graph/index.php
+++ b/src/Gateway/Graph/index.php
@@ -1,4 +1,5 @@
waitForCompletion($id);
}
@@ -65,7 +67,7 @@ function getOrchestrationStatus(array $args, DurableClient $context): array
function getEntitySnapshot(array $args, DurableClient $context): array
{
- $id = new EntityId($args['id']['name'], $args['id']['id']);
+ $id = EntityId($args['id']['name'], $args['id']['id']);
return Serializer::serialize($context->getEntitySnapshot($id));
}
@@ -90,7 +92,7 @@ function startOrchestration(array $args, DurableClient $context): array
function raiseEvent(array $args, DurableClient $context): array
{
- $id = new OrchestrationInstance($args['id']['instance'], $args['id']['execution']);
+ $id = OrchestrationInstance($args['id']['instance'], $args['id']['execution']);
$arguments = array_map(
static fn($x, $i) => ['key' => $i, ...$x],
$args['arguments'],
@@ -104,7 +106,7 @@ function raiseEvent(array $args, DurableClient $context): array
function signal(array $args, DurableClient $context): array
{
- $id = new EntityId($args['id']['name'], $args['id']['id']);
+ $id = EntityId($args['id']['name'], $args['id']['id']);
$signal = $args['signal'];
unset($args['id'], $args['signal']);
diff --git a/src/Glue/glue.php b/src/Glue/glue.php
index ebc6f123..1c8e94f9 100644
--- a/src/Glue/glue.php
+++ b/src/Glue/glue.php
@@ -1,4 +1,5 @@
method = $_SERVER['HTTP_DPHP_FUNCTION'];
try {
$provenance = json_decode($_SERVER['HTTP_DPHP_PROVENANCE'] ?? 'null', true, 32, JSON_THROW_ON_ERROR);
- if (!$provenance || $provenance === ['userId' => '', 'roles' => null]) {
+ if (! $provenance || $provenance === ['userId' => '', 'roles' => null]) {
$this->provenance = null;
} else {
$provenance['roles'] ??= [];
@@ -100,7 +102,7 @@ public function __construct(private DurableLogger $logger)
$this->provenance = null;
}
- if (!file_exists($_SERVER['HTTP_DPHP_PAYLOAD'])) {
+ if (! file_exists($_SERVER['HTTP_DPHP_PAYLOAD'])) {
throw new LogicException('Unable to load payload');
}
@@ -185,14 +187,14 @@ public function outputEvent(EventDescription $event): void
{
// determine access level
- echo 'EVENT~!~' . trim($event->toStream()) . "\n";
+ echo 'EVENT~!~' . mb_trim($event->toStream()) . "\n";
}
private function startOrchestration(): void
{
- if (!$this->target->toOrchestrationInstance()->executionId) {
+ if (! $this->target->toOrchestrationInstance()->executionId) {
$this->target = StateId::fromInstance(
- new OrchestrationInstance(
+ OrchestrationInstance(
$this->target->toOrchestrationInstance()->instanceId,
Uuid::uuid7()->toString(),
),
@@ -202,8 +204,7 @@ private function startOrchestration(): void
header('X-Id: ' . $this->target->id);
$input = SerializedArray::import($this->payload['input'])->toArray();
- $event =
- WithOrchestration::forInstance($this->target, StartExecution::asParent($input, []/* todo: scheduling */));
+ $event = WithOrchestration::forInstance($this->target, StartExecution::asParent($input, []/* todo: scheduling */));
$this->outputEvent(new EventDescription($event));
$actualId = $this->target->toOrchestrationInstance();
@@ -320,7 +321,7 @@ private function getPermissions(): void
break;
case $attribute->getName() === TimeToLive::class:
/** @var TimeToLive $attribute */ $attribute = $attribute->newInstance();
- $permissions['ttl'] = $attribute->timeToLive()->as(TimeUnit::Nanoseconds);
+ $permissions['ttl'] = $attribute->timeToLive()->as(Unit::Nanoseconds);
break;
}
}
diff --git a/src/OrchestrationClientInterface.php b/src/OrchestrationClientInterface.php
index 2a35039d..69f490c6 100644
--- a/src/OrchestrationClientInterface.php
+++ b/src/OrchestrationClientInterface.php
@@ -24,8 +24,10 @@
namespace Bottledcode\DurablePhp;
+use Bottledcode\DurablePhp\Events\Shares\Operation;
use Bottledcode\DurablePhp\State\OrchestrationInstance;
use Bottledcode\DurablePhp\State\Status;
+use Generator;
interface OrchestrationClientInterface
{
@@ -33,7 +35,7 @@ public function withAuth(string $token): void;
public function getStatus(OrchestrationInstance $instance): Status;
- public function listInstances(/* todo */): \Generator;
+ public function listInstances(/* todo */): Generator;
public function purge(OrchestrationInstance $instance): void;
@@ -43,11 +45,21 @@ public function restart(OrchestrationInstance $instance): void;
public function resume(OrchestrationInstance $instance, string $reason): void;
- public function startNew(string $name, array $args = [], string|null $id = null): OrchestrationInstance;
+ public function startNew(string $name, array $args = [], ?string $id = null): OrchestrationInstance;
public function suspend(OrchestrationInstance $instance, string $reason): void;
public function terminate(OrchestrationInstance $instance, string $reason): void;
public function waitForCompletion(OrchestrationInstance $instance): void;
+
+ public function shareOrchestrationOwnership(OrchestrationInstance $id, string $with): void;
+
+ public function grantOrchestrationAccessToUser(OrchestrationInstance $id, string $user, Operation $operation): void;
+
+ public function grantOrchestrationAccessToRole(OrchestrationInstance $id, string $role, Operation $operation): void;
+
+ public function revokeOrchestrationAccessToUser(OrchestrationInstance $id, string $user): void;
+
+ public function revokeOrchestrationAccessToRole(OrchestrationInstance $id, string $role): void;
}
diff --git a/src/OrchestrationContext.php b/src/OrchestrationContext.php
index c97b3fd6..01019b0e 100644
--- a/src/OrchestrationContext.php
+++ b/src/OrchestrationContext.php
@@ -76,12 +76,13 @@ public function callActivity(string $name, array $args = [], ?RetryOptions $retr
$identity = $this->newGuid();
return $this->createFuture(
- fn() => $this->taskController->fire(
- AwaitResult::forEvent(
- StateId::fromInstance($this->id),
- WithActivity::forEvent($identity, ScheduleTask::forName($name, $args)),
+ fn()
+ => $this->taskController->fire(
+ AwaitResult::forEvent(
+ StateId::fromInstance($this->id),
+ WithActivity::forEvent($identity, ScheduleTask::forName($name, $args)),
+ ),
),
- ),
function (Event $event, string $eventIdentity) use ($identity): array {
if (($event instanceof TaskCompleted || $event instanceof TaskFailed) &&
$eventIdentity === $identity->toString()) {
@@ -114,7 +115,7 @@ private function createFuture(
?string $identity = null,
): DurableFuture {
$identity ??= $this->history->historicalTaskResults->getIdentity();
- if (! $this->history->historicalTaskResults->hasSentIdentity($identity)) {
+ if (!$this->history->historicalTaskResults->hasSentIdentity($identity)) {
$this->durableLogger->debug('Future requested for an unsent identity', [$identity]);
[$eventId] = $onSent();
$deferred = new DeferredFuture();
@@ -141,7 +142,12 @@ public function callActivityInline(Closure $activity): DurableFuture
return $this->createFuture(function () use ($activity, $identity) {
try {
$result = $activity();
- $this->taskController->fire(WithOrchestration::forInstance(StateId::fromInstance($this->id), TaskCompleted::forId($identity->toString(), $result)));
+ $this->taskController->fire(
+ WithOrchestration::forInstance(
+ StateId::fromInstance($this->id),
+ TaskCompleted::forId($identity->toString(), $result),
+ ),
+ );
return [$identity];
} catch (Throwable $exception) {
@@ -160,7 +166,8 @@ public function callActivityInline(Closure $activity): DurableFuture
return [$identity];
}
}, function (Event $event, string $eventIdentity) use ($identity): array {
- if (($event instanceof TaskCompleted || $event instanceof TaskFailed) && $eventIdentity === $identity->toString()) {
+ if (($event instanceof TaskCompleted || $event instanceof TaskFailed) &&
+ $eventIdentity === $identity->toString()) {
return [$event, true];
}
@@ -189,7 +196,10 @@ public function continueAsNew(array $args = []): never
$this->history->restartAsNew($args);
$this->taskController->fire(
- WithOrchestration::forInstance(StateId::fromInstance($this->id), StartOrchestration::forInstance($this->id)),
+ WithOrchestration::forInstance(
+ StateId::fromInstance($this->id),
+ StartOrchestration::forInstance($this->id),
+ ),
);
throw new Unwind();
}
@@ -204,12 +214,13 @@ public function createTimer(DateTimeImmutable|DateInterval $fireAt): DurableFutu
$identity = sha1($fireAt->format('c'));
return $this->createFuture(
- fn() => $this->taskController->fire(
- WithOrchestration::forInstance(
- StateId::fromInstance($this->id),
- WithDelay::forEvent($fireAt, RaiseEvent::forTimer($identity)),
+ fn()
+ => $this->taskController->fire(
+ WithOrchestration::forInstance(
+ StateId::fromInstance($this->id),
+ WithDelay::forEvent($fireAt, RaiseEvent::forTimer($identity)),
+ ),
),
- ),
function (Event $event) use ($identity): array {
if ($event instanceof RaiseEvent && $event->eventName === $identity) {
return [$event, true];
@@ -300,13 +311,11 @@ public function createInterval(
?int $seconds = null,
?int $microseconds = null,
): DateInterval {
- if (
- empty(
- array_filter(
- compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'),
- )
+ if (empty(
+ array_filter(
+ compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'),
)
- ) {
+ )) {
throw new LogicException('At least one interval part must be specified');
}
@@ -361,7 +370,7 @@ public function isLockedOwned(EntityId $entityId): bool
public function lockEntity(EntityId ...$entityId): EntityLock
{
$this->durableLogger->debug('Locking entities', ['entityId' => $entityId]);
- if (! empty($this->history->locks ?? []) && ! $this->isReplaying()) {
+ if (!empty($this->history->locks ?? []) && !$this->isReplaying()) {
throw new LogicException('Cannot lock an entity while holding locks');
}
@@ -375,12 +384,11 @@ public function lockEntity(EntityId ...$entityId): EntityLock
WithEntity::forInstance(current($entityId), RaiseEvent::forLockNotification($owner->id)),
);
$identity = $this->newGuid()->toString();
- $future =
- $this->createFuture(
- fn() => $this->taskController->fire(WithLock::onEntity($owner, $event, ...$entityId)),
- fn(Event $event, string $eventIdentity) => [$event, $identity === $eventIdentity],
- $identity,
- );
+ $future = $this->createFuture(
+ fn() => $this->taskController->fire(WithLock::onEntity($owner, $event, ...$entityId)),
+ fn(Event $event, string $eventIdentity) => [$event, $identity === $eventIdentity],
+ $identity,
+ );
$this->waitOne($future);
$this->history->locks = $entityId;
@@ -439,17 +447,17 @@ public function waitAll(DurableFuture ...$tasks): array
/**
* @template T
*
- * @param class-string $className
+ * @param class-string $className
* @return T
*/
public function createEntityProxy(string $className, ?EntityId $entityId = null): object
{
if ($entityId === null) {
- $entityId = new EntityId($className, $this->newGuid());
+ $entityId = EntityId($className, $this->newGuid());
}
$class = new ReflectionClass($className);
- if (! $class->isInterface()) {
+ if (!$class->isInterface()) {
throw new LogicException('Only interfaces can be proxied');
}
@@ -569,7 +577,7 @@ public function entityOp(string|EntityId $id, Closure $operation): mixed
}
$name = $type->getName();
- if (! interface_exists($name)) {
+ if (!interface_exists($name)) {
throw new LogicException('Unable to load interface: ' . $name);
}
@@ -588,7 +596,7 @@ public function entityOp(string|EntityId $id, Closure $operation): mixed
throw new LogicException('Did not call an operation');
}
- $entityId = $id instanceof EntityId ? $id : new EntityId($name, $id);
+ $entityId = $id instanceof EntityId ? $id : EntityId($name, $id);
if ($returns) {
return $this->waitOne($this->callEntity($entityId, $operationName, $arguments));
diff --git a/src/Proxy/ClientProxy.php b/src/Proxy/ClientProxy.php
index 8e6fcdfa..5cdc2f5c 100644
--- a/src/Proxy/ClientProxy.php
+++ b/src/Proxy/ClientProxy.php
@@ -1,4 +1,5 @@
getName();
+ if ($isHook) {
+ $name = explode('::', $method->getName())[0];
+ $name = str_replace('$', '', $name);
+ $name = ucfirst($name);
+ } else {
+ $name = $method->getName();
+ }
$params = $method->getParameters();
$params = array_map(
- function (\ReflectionParameter $param) {
+ function (ReflectionParameter $param) {
$type = $param->getType();
if ($type !== null) {
$type = $this->getTypes($type);
@@ -44,29 +55,41 @@ function (\ReflectionParameter $param) {
$return = $method->getReturnType();
$return = $return ? ": {$this->getTypes($return)}" : '';
+ if ($isHook) {
+ return <<source->__set{$name}(\$value);
+ }
+ EOT;
+ }
+
return <<source->{$name}(...func_get_args());
-}
-EOT;
+ public function {$name}({$params}){$return} {
+ return \$this->source->{$name}(...func_get_args());
+ }
+ EOT;
}
- protected function getName(\ReflectionClass $class): string
+ protected function getName(ReflectionClass $class): string
{
return "__ClientProxy_{$class->getShortName()}";
}
- protected function impureSignal(\ReflectionMethod $method): string
+ protected function impureSignal(ReflectionMethod $method): string
{
return $this->impureCall($method);
}
- protected function impureCall(\ReflectionMethod $method): string
+ protected function impureCall(ReflectionMethod $method, bool $isHook = false): string
{
- $name = $method->getName();
+ if ($isHook) {
+ $name = 'get';
+ } else {
+ $name = $method->getName();
+ }
$params = $method->getParameters();
$params = array_map(
- function (\ReflectionParameter $param) {
+ function (ReflectionParameter $param) {
$type = $param->getType();
if ($type !== null) {
$type = $this->getTypes($type);
@@ -80,17 +103,25 @@ function (\ReflectionParameter $param) {
$return = $method->getReturnType();
$return = $return ? ": {$this->getTypes($return)}" : '';
+ if ($isHook) {
+ return <<getName($class = new \ReflectionClass($interface));
+ $name = $this->getName($class = new ReflectionClass($interface));
$namespace = $this->getInterfaceNamespace($class);
$cacheFile = null;
if ($this->cacheDir) {
@@ -42,7 +49,7 @@ public function define(string $interface): string
}
}
- $reflection = new \ReflectionClass($interface);
+ $reflection = new ReflectionClass($interface);
$fullname = $this->getInterfaceNamespace($reflection) . '\\' . $this->getName($reflection);
if (!class_exists($fullname)) {
@@ -55,21 +62,21 @@ public function define(string $interface): string
return '\\' . $namespace . '\\' . $name;
}
- protected function getInterfaceNamespace(\ReflectionClass $class): string
+ abstract protected function getName(ReflectionClass $class): string;
+
+ protected function getInterfaceNamespace(ReflectionClass $class): string
{
return $class->getNamespaceName();
}
- abstract protected function getName(\ReflectionClass $class): string;
-
public function generate(string $interface): string
{
- $reflection = new \ReflectionClass($interface);
+ $reflection = new ReflectionClass($interface);
$methods = $reflection->getMethods();
$namespace = $reflection->getNamespaceName();
$className = $reflection->getShortName();
$methods = array_map(
- function (\ReflectionMethod $method) {
+ function (ReflectionMethod $method) {
if ($method->getAttributes(Pure::class)) {
return $this->pureMethod($method);
}
@@ -79,7 +86,7 @@ function (\ReflectionMethod $method) {
}
$return = $method->getReturnType();
- if (($return instanceof \ReflectionNamedType) && $return->getName() === 'void') {
+ if (($return instanceof ReflectionNamedType) && $return->getName() === 'void') {
return $this->impureSignal($method);
}
@@ -89,42 +96,62 @@ function (\ReflectionMethod $method) {
);
$methods = implode("\n", $methods);
$namespace = $namespace ? "namespace {$namespace};" : '';
-
+ $props = $reflection->getProperties(ReflectionProperty::IS_PUBLIC);
+ $props = array_map(function (ReflectionProperty $prop) {
+ $hooks = ['public ' . $this->getTypes($prop->getType()) . ' $' . $prop->getName() . ' {'];
+ if ($hook = $prop->getHook(PropertyHookType::Get)) {
+ $hooks[] = $this->impureCall($hook, true);
+ }
+ if ($hook = $prop->getHook(PropertyHookType::Set)) {
+ $hooks[] = $this->pureMethod($hook, true);
+ }
+ $hooks[] = '}';
+ return implode(
+ "\n",
+ array_filter(
+ $hooks,
+ fn($hook) => $hook !== '',
+ ),
+ );
+ }, $props);
+ $props = implode("\n", $props);
return <<getName($reflection)} implements {$className} {
- {$this->preamble($reflection)}
- {$methods}
-}
-EOT;
+ {$namespace}
+
+ class {$this->getName($reflection)} implements {$className} {
+ {$this->preamble($reflection)}
+ {$props}
+ {$methods}
+ }
+ EOT;
}
- abstract protected function pureMethod(\ReflectionMethod $method): string;
+ abstract protected function pureMethod(ReflectionMethod $method, bool $isHook = false): string;
- abstract protected function impureSignal(\ReflectionMethod $method): string;
+ abstract protected function impureSignal(ReflectionMethod $method): string;
- abstract protected function impureCall(\ReflectionMethod $method): string;
+ abstract protected function impureCall(ReflectionMethod $method, bool $isHook = false): string;
- abstract protected function preamble(\ReflectionClass $class): string;
-
- protected function getTypes(\ReflectionNamedType|ReflectionUnionType|\ReflectionIntersectionType|null $type): string
+ protected function getTypes(ReflectionNamedType|ReflectionUnionType|ReflectionIntersectionType|null $type): string
{
- if ($type instanceof \ReflectionNamedType) {
- if($type->isBuiltin()) {
- return $type->getName();
+ if ($type instanceof ReflectionNamedType) {
+ $nullable = $type->allowsNull() ? '?' : '';
+ if ($type->isBuiltin()) {
+ return $nullable . $type->getName();
}
- return '\\' . $type->getName();
+ return $nullable . '\\' . $type->getName();
}
if ($type instanceof ReflectionUnionType) {
return implode('|', array_map($this->getTypes(...), $type->getTypes()));
}
- if ($type instanceof \ReflectionIntersectionType) {
+ if ($type instanceof ReflectionIntersectionType) {
return implode('&', array_map($this->getTypes(...), $type->getTypes()));
}
return '';
}
+
+ abstract protected function preamble(ReflectionClass $class): string;
}
diff --git a/src/Proxy/OrchestratorProxy.php b/src/Proxy/OrchestratorProxy.php
index 026ee207..dc8712de 100644
--- a/src/Proxy/OrchestratorProxy.php
+++ b/src/Proxy/OrchestratorProxy.php
@@ -1,4 +1,5 @@
impureCall($method);
+ return $this->impureCall($method, $isHook);
}
- protected function impureCall(ReflectionMethod $method): string
+ protected function impureCall(ReflectionMethod $method, bool $isHook = false): string
{
- $name = $method->getName();
+ $getHook = 'return ';
+ if ($isHook && str_ends_with($method->getName(), 'get')) {
+ $name = 'get';
+ } elseif ($isHook && str_ends_with($method->getName(), 'set')) {
+ $name = 'set';
+ $getHook = '';
+ } else {
+ $name = $method->getName();
+ }
$params = $method->getParameters();
$params = array_map(
function (ReflectionParameter $param) {
@@ -53,11 +62,26 @@ function (ReflectionParameter $param) {
$return = $method->getReturnType();
$return = $return ? ": {$this->getTypes($return)}" : '';
+ if ($isHook) {
+ $hookName = $method->getName();
+ if ($getHook) {
+ $value = '[]';
+ } else {
+ $value = '[$value]';
+ }
+ $hookName = str_replace('$', '\$', $hookName);
+ return <<context->waitOne(\$this->context->callEntity(\$this->id, "{$hookName}", {$value}));
+ }
+ EOT;
+ }
+
return <<context->waitOne(\$this->context->callEntity(\$this->id, "{$method->getName()}", func_get_args()));
-}
-EOT;
+ public function {$name}({$params}){$return} {
+ return \$this->context->waitOne(\$this->context->callEntity(\$this->id, "{$method->getName()}", func_get_args()));
+ }
+ EOT;
}
protected function getName(ReflectionClass $class): string
@@ -85,16 +109,16 @@ function (ReflectionParameter $param) {
$return = $return ? ": {$this->getTypes($return)}" : '';
return <<context->signalEntity(\$this->id, "{$method->getName()}", func_get_args());
-}
-EOT;
+ public function {$name}({$params}){$return} {
+ \$this->context->signalEntity(\$this->id, "{$method->getName()}", func_get_args());
+ }
+ EOT;
}
protected function preamble(ReflectionClass $class): string
{
return <<impureCall($method);
+ return $this->impureCall($method, $isHook);
}
- protected function impureCall(\ReflectionMethod $method): string
+ protected function impureCall(ReflectionMethod $method, bool $isHook = false): string
{
- $name = $method->getName();
+ if ($isHook && str_ends_with($method->getName(), 'get')) {
+ $name = 'get';
+ } elseif ($isHook && str_ends_with($method->getName(), 'set')) {
+ $name = 'set';
+ } else {
+ $name = $method->getName();
+ }
+
$params = $method->getParameters();
$params = array_map(
- function (\ReflectionParameter $param) {
+ function (ReflectionParameter $param) {
$type = $param->getType();
if ($type !== null) {
$type = $this->getTypes($type);
@@ -49,29 +61,66 @@ function (\ReflectionParameter $param) {
$return = $method->getReturnType();
$return = $return ? ": {$this->getTypes($return)}" : '';
+ if ($isHook) {
+ $hookName = $method->getName();
+ if (str_ends_with($hookName, 'set')) {
+ $value = '[$value]';
+ } else {
+ $value = '[]';
+ }
+ $hookName = str_replace('$', '\$', $hookName);
+
+ return <<operation = "{$hookName}";
+ \$this->arguments = {$value};
+ throw new \Bottledcode\DurablePhp\Proxy\SpyException('do not call outside of context');
+ }
+ EOT;
+ }
+
return <<operation = "{$name}";
- \$this->arguments = func_get_args();
- throw new \Exception('Not implemented');
-}
-EOT;
+ public function {$name}({$params}){$return} {
+ \$this->operation = "{$name}";
+ \$this->arguments = func_get_args();
+ throw new \Bottledcode\DurablePhp\Proxy\SpyException('do not call outside of context');
+ }
+ EOT;
}
- protected function getName(\ReflectionClass $class): string
+ protected function getName(ReflectionClass $class): string
{
return "__SpyProxy_{$class->getShortName()}";
}
- protected function impureSignal(\ReflectionMethod $method): string
+ protected function impureSignal(ReflectionMethod $method): string
{
return $this->impureCall($method);
}
- protected function preamble(\ReflectionClass $class): string
+ protected function preamble(ReflectionClass $class): string
{
return <<<'EOT'
-public function __construct(private string|null &$operation = null, private array|null &$arguments = null) {}
-EOT;
+ private string|null $operation {
+ get => $this->op;
+ set {
+ if ($this->op !== null) {
+ throw new \LogicException('Can only send one signal at a time');
+ }
+ $this->op = $value;
+ }
+ }
+ private array|null $arguments {
+ get => $this->args;
+ set {
+ if ($this->args !== null) {
+ throw new \LogicException('Can only send one signal at a time');
+ }
+ $this->args = $value;
+ }
+ }
+
+ public function __construct(private string|null &$op = null, private array|null &$args = null) {}
+ EOT;
}
}
diff --git a/src/RemoteEntityClient.php b/src/RemoteEntityClient.php
index 7a92953e..8b2dc8a0 100644
--- a/src/RemoteEntityClient.php
+++ b/src/RemoteEntityClient.php
@@ -1,4 +1,5 @@
apiHost = rtrim($this->apiHost, '/');
+ $this->apiHost = mb_rtrim($this->apiHost, '/');
}
#[Override]
@@ -56,7 +58,11 @@ public function cleanEntityStorage(): void {}
#[Override]
public function listEntities(EntityFilter $filter, int $page): Generator
{
- $req = new Request($this->apiHost . '/entities/filter/' . $page, 'POST', json_encode($filter, JSON_THROW_ON_ERROR));
+ $req = new Request(
+ $this->apiHost . '/entities/filter/' . $page,
+ 'POST',
+ json_encode($filter, JSON_THROW_ON_ERROR),
+ );
if ($this->userToken) {
$req->setHeader('Authorization', 'Bearer ' . $this->userToken);
}
@@ -74,15 +80,24 @@ public function signal(EntityId|string $entityId, Closure $signal): void
throw new Exception("Interface {$interfaceName} does not exist");
}
$spy = $this->spyProxy->define($interfaceName);
- $operationName = '';
- $arguments = [];
+ $operationName = null;
+ $arguments = null;
try {
$class = new $spy($operationName, $arguments);
$signal($class);
- } catch (Throwable) {
- // spies always throw
+ } catch (SpyException) {
+ // we have completed the spy
+ }
+
+ if ($operationName === null || $arguments === null) {
+ return;
}
- $this->signalEntity(is_string($entityId) ? new EntityId($interfaceName, $entityId) : $entityId, $operationName, $arguments);
+
+ $this->signalEntity(
+ is_string($entityId) ? EntityId($interfaceName, $entityId) : $entityId,
+ $operationName,
+ $arguments,
+ );
}
#[Override]
@@ -149,4 +164,64 @@ public function deleteEntity(EntityId $entityId): void
throw new Exception('Failed to delete entity');
}
}
+
+ public function shareEntityOwnership(EntityId $id, string $with): void
+ {
+ $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/share/{$with}", 'PUT');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to share ownership');
+ }
+ }
+
+ public function grantEntityAccessToUser(EntityId $id, string $user, Operation $operation): void
+ {
+ $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/user/{$user}/{$operation->value}", 'PUT');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
+
+ public function grantEntityAccessToRole(EntityId $id, string $role, Operation $operation): void
+ {
+ $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/role/{$role}/{$operation->value}", 'PUT');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
+
+ public function revokeEntityAccessToUser(EntityId $id, string $user): void
+ {
+ $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/user/{$user}", 'DELETE');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
+
+ public function revokeEntityAccessToRole(EntityId $id, string $role): void
+ {
+ $req = new Request("{$this->apiHost}/entity/{$id->name}/{$id->id}/grant/role/{$role}", 'DELETE');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
}
diff --git a/src/RemoteOrchestrationClient.php b/src/RemoteOrchestrationClient.php
index 9be72cf0..1be81766 100644
--- a/src/RemoteOrchestrationClient.php
+++ b/src/RemoteOrchestrationClient.php
@@ -1,4 +1,5 @@
apiHost = rtrim($this->apiHost, '/');
+ $this->apiHost = mb_rtrim($this->apiHost, '/');
}
#[Override]
@@ -146,7 +148,7 @@ public function startNew(string $name, array $args = [], ?string $id = null): Or
throw new Exception($result->getBody()->buffer());
}
- return (new StateId($result->getHeader('X-Id')))->toOrchestrationInstance();
+ return StateId::fromString($result->getHeader('X-Id'))->toOrchestrationInstance();
}
#[Override]
@@ -167,9 +169,9 @@ public function waitForCompletion(OrchestrationInstance $instance): void
$name = rawurlencode($instance->instanceId);
$id = rawurlencode($instance->executionId);
$req = new Request("{$this->apiHost}/orchestration/{$name}/{$id}?wait=60");
- $req->setInactivityTimeout(Hours(1)->as(TimeUnit::Seconds));
- $req->setTcpConnectTimeout(Seconds(30)->as(TimeUnit::Seconds));
- $req->setTransferTimeout(Hours(1)->as(TimeUnit::Seconds));
+ $req->setInactivityTimeout(Hours(1)->as(Unit::Seconds));
+ $req->setTcpConnectTimeout(Seconds(30)->as(Unit::Seconds));
+ $req->setTransferTimeout(Hours(1)->as(Unit::Seconds));
if ($this->userToken) {
$req->setHeader('Authorization', 'Bearer ' . $this->userToken);
}
@@ -191,4 +193,64 @@ public function withAuth(string $token): void
{
$this->userToken = $token;
}
+
+ public function shareOrchestrationOwnership(OrchestrationInstance $id, string $with): void
+ {
+ $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/share/{$with}", 'PUT');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to share ownership');
+ }
+ }
+
+ public function grantOrchestrationAccessToUser(OrchestrationInstance $id, string $user, Operation $operation): void
+ {
+ $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/user/{$user}/{$operation->value}", 'PUT');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
+
+ public function grantOrchestrationAccessToRole(OrchestrationInstance $id, string $role, Operation $operation): void
+ {
+ $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/role/{$role}/{$operation->value}", 'PUT');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
+
+ public function revokeOrchestrationAccessToUser(OrchestrationInstance $id, string $user): void
+ {
+ $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/user/{$user}", 'DELETE');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
+
+ public function revokeOrchestrationAccessToRole(OrchestrationInstance $id, string $role): void
+ {
+ $req = new Request("{$this->apiHost}/orchestration/{$id->instanceId}/{$id->executionId}/grant/role/{$role}", 'DELETE');
+ if ($this->userToken) {
+ $req->setHeader('Authorization', 'Bearer ' . $this->userToken);
+ }
+ $result = $this->client->request($req);
+ if ($result->getStatus() !== 200) {
+ throw new Exception('Failed to grant access');
+ }
+ }
}
diff --git a/src/State/EntityHistory.php b/src/State/EntityHistory.php
index 0a07121c..e4af7e32 100644
--- a/src/State/EntityHistory.php
+++ b/src/State/EntityHistory.php
@@ -68,8 +68,12 @@ class EntityHistory extends AbstractHistory
private LockStateMachine $lockQueue;
- public function __construct(public StateId $id, #[Field(exclude: true)] public ?DurableLogger $logger, private Provenance $user)
- {
+ public function __construct(
+ public StateId $id,
+ #[Field(exclude: true)]
+ public ?DurableLogger $logger,
+ private Provenance $user,
+ ) {
$this->entityId = $id->toEntityId();
}
@@ -118,10 +122,12 @@ public function applyRaiseEvent(RaiseEvent $event, Event $original): Generator
// reply to the lock request
$reply = $this->getReplyTo($original);
foreach ($reply as $nextEvent) {
- yield WithPriority::high(With::id(
- $nextEvent,
- RaiseEvent::forLock('locked', $event->eventData['owner'], $event->eventData['target']),
- ));
+ yield WithPriority::high(
+ With::id(
+ $nextEvent,
+ RaiseEvent::forLock('locked', $event->eventData['owner'], $event->eventData['target']),
+ ),
+ );
}
break;
case '__unlock':
@@ -156,11 +162,19 @@ public function init(): void
}
$this->lockQueue ??= new LockStateMachine($this->id);
- $this->state ??= new class () extends EntityState {};
+ $this->state ??= new class extends EntityState {};
$this->name = $this->id->toEntityId()->name;
$now = MonotonicClock::current()->now();
- $this->status = new Status($now, '', SerializedArray::fromArray([]), $this->id, $now, SerializedArray::fromArray([]), RuntimeStatus::Running);
+ $this->status = new Status(
+ $now,
+ '',
+ SerializedArray::fromArray([]),
+ $this->id,
+ $now,
+ SerializedArray::fromArray([]),
+ RuntimeStatus::Running,
+ );
$this->state = $this->container->get($this->name);
}
@@ -197,6 +211,17 @@ private function execute(Event $original, string $operation, array $input): Gene
}
}
try {
+ if (str_contains($operation, '::')) {
+ [$property, $operation] = explode('::', $operation);
+ $property = str_replace('$', '', $property);
+ $result = match ($operation) {
+ 'get' => $this->state->{$property},
+ 'set' => $this->state->{$property} = $input[0],
+ default => throw new ReflectionException('Unknown operation'),
+ };
+ goto finalize;
+ }
+
$operationReflection = $reflector->getMethod($operation);
} catch (ReflectionException) {
// search attributes for matching operation
@@ -239,9 +264,13 @@ private function execute(Event $original, string $operation, array $input): Gene
}
}
+ finalize:
+
if ($replyTo) {
foreach ($replyTo as $reply) {
- yield WithPriority::high(WithOrchestration::forInstance($reply, TaskCompleted::forId($original->eventId, $result ?? null)));
+ yield WithPriority::high(
+ WithOrchestration::forInstance($reply, TaskCompleted::forId($original->eventId, $result ?? null)),
+ );
}
}
}
@@ -257,11 +286,10 @@ private function finalize(Event $event): Generator
$now = time();
$cutoff = $now - 3600; // 1 hour
$this->history[$event->eventId] = $this->debugHistory ? $event : $now;
- $this->history =
- array_filter(
- $this->history,
- static fn(int|bool|Event $value) => is_int($value) ? $value > $cutoff : $value,
- );
+ $this->history = array_filter(
+ $this->history,
+ static fn(int|bool|Event $value) => is_int($value) ? $value > $cutoff : $value,
+ );
$this->status = $this->status->with(lastUpdated: MonotonicClock::current()->now());
yield null;
diff --git a/src/State/EntityId.php b/src/State/EntityId.php
index e968df8d..22886302 100644
--- a/src/State/EntityId.php
+++ b/src/State/EntityId.php
@@ -25,17 +25,24 @@
namespace Bottledcode\DurablePhp\State;
use Stringable;
+use Withinboredom\Record;
/**
* @template T
*/
-readonly class EntityId implements Stringable
+readonly class EntityId extends Record implements Stringable
{
+ public protected(set) string $name;
+
+ public protected(set) string $id;
+
/**
- * @param class-string $name
- * @param string $id
+ * @param class-string $name
*/
- public function __construct(public string $name, public string $id) {}
+ public static function from(string $name, string $id): static
+ {
+ return self::fromArgs(name: $name, id: $id);
+ }
public function __toString(): string
{
diff --git a/src/State/Exporter.php b/src/State/Exporter.php
new file mode 100644
index 00000000..a0a86098
--- /dev/null
+++ b/src/State/Exporter.php
@@ -0,0 +1,47 @@
+getMethod('getIdentity')->invoke($value);
+
+ return parent::exportValue($serializer, $field, $id, $runningValue);
+ }
+
+ public function canExport(Field $field, mixed $value, string $format): bool
+ {
+ return $value instanceof Record;
+ }
+
+ public function importValue(Deserializer $deserializer, Field $field, mixed $source): mixed
+ {
+ $reflectedRecord = new ReflectionClass($field->phpType);
+ if ($source === null || $source[$field->phpName] === null) {
+ return null;
+ }
+
+ $record = $reflectedRecord->getMethod('fromArgs')->invoke(null, ...($source[$field->phpName]));
+
+ return $record;
+ }
+
+ public function canImport(Field $field, string $format): bool
+ {
+ return is_a($field->phpType, Record::class, true);
+ }
+}
diff --git a/src/State/Ids/StateId.php b/src/State/Ids/StateId.php
index cbfbc1ce..a4839cce 100644
--- a/src/State/Ids/StateId.php
+++ b/src/State/Ids/StateId.php
@@ -34,11 +34,17 @@
use Exception;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
+use RuntimeException;
+use Stringable;
+use Withinboredom\Record;
+
+use function Bottledcode\DurablePhp\EntityId;
+use function Bottledcode\DurablePhp\OrchestrationInstance;
#[ClassNameTypeMap('__type')]
-readonly class StateId implements \Stringable
+readonly class StateId extends Record implements Stringable
{
- public function __construct(public string $id) {}
+ public protected(set) string $id;
public static function fromState(StateInterface $state): self
{
@@ -51,22 +57,23 @@ public static function fromState(StateInterface $state): self
public static function fromInstance(OrchestrationInstance $instance): self
{
- return new self("orchestration:{$instance}");
+ return self::fromArgs(id: "orchestration:{$instance}");
}
public static function fromActivityId(UuidInterface|string $activityId): self
{
- return new self("activity:{$activityId}");
+ return self::fromArgs(id: "activity:{$activityId}");
}
public static function fromEntityId(EntityId $entityId): self
{
- return new self("entity:{$entityId}");
+ return self::fromArgs(id: "entity:{$entityId}");
}
public function toActivityId(): string
{
$parts = explode(':', $this->id, 3);
+
return match ($parts) {
['orchestration', $parts[1]] => throw new Exception('Cannot convert orchestration state to activity id'),
['activity', $parts[1]] => Uuid::fromString($parts[1])->toString(),
@@ -76,15 +83,16 @@ public function toActivityId(): string
public static function fromString(string $id): self
{
- return new self($id);
+ return self::fromArgs(id: $id);
}
public function toOrchestrationInstance(): OrchestrationInstance
{
$parts = explode(':', $this->id, 3);
+
return match ($parts) {
['activity', $parts[1]] => throw new Exception('Cannot convert activity state to orchestration instance'),
- ['orchestration', $parts[1], $parts[2]] => new OrchestrationInstance($parts[1], $parts[2]),
+ ['orchestration', $parts[1], $parts[2]] => OrchestrationInstance($parts[1], $parts[2]),
['entity', $parts[1], $parts[2]] => throw new Exception(
'Cannot convert entity state to orchestration instance',
),
@@ -94,12 +102,13 @@ public function toOrchestrationInstance(): OrchestrationInstance
public function toEntityId(): EntityId
{
$parts = explode(':', $this->id, 3);
+
return match ($parts) {
['activity', $parts[1]] => throw new Exception('Cannot convert activity state to entity id'),
['orchestration', $parts[1], $parts[2]] => throw new Exception(
'Cannot convert orchestration state to entity id',
),
- ['entity', $parts[1], $parts[2]] => new EntityId($parts[1], $parts[2]),
+ ['entity', $parts[1], $parts[2]] => EntityId($parts[1], $parts[2]),
};
}
@@ -114,6 +123,7 @@ public function isActivityId(): bool
public function getStateType(): string
{
$parts = explode(':', $this->id, 3);
+
return match ($parts) {
['activity', $parts[1]] => ActivityHistory::class,
['orchestration', $parts[1], $parts[2]] => OrchestrationHistory::class,
@@ -121,7 +131,7 @@ public function getStateType(): string
};
}
- public function getPartitionKey(int $totalPartitions): int|null
+ public function getPartitionKey(int $totalPartitions): ?int
{
return match ($this->isPartitioned()) {
true => crc32($this->id) % $totalPartitions,
@@ -162,7 +172,7 @@ public function __invoke(string|StateId|OrchestrationInstance|EntityId|UuidInter
return self::fromActivityId($id);
}
- throw new \RuntimeException("Cannot convert {$id} to StateId");
+ throw new RuntimeException("Cannot convert {$id} to StateId");
}
public function __toString(): string
diff --git a/src/State/OrchestrationInstance.php b/src/State/OrchestrationInstance.php
index 9e21bb9b..15869daf 100644
--- a/src/State/OrchestrationInstance.php
+++ b/src/State/OrchestrationInstance.php
@@ -24,9 +24,19 @@
namespace Bottledcode\DurablePhp\State;
-readonly class OrchestrationInstance implements \Stringable
+use Stringable;
+use Withinboredom\Record;
+
+readonly class OrchestrationInstance extends Record implements Stringable
{
- public function __construct(public string $instanceId, public string $executionId) {}
+ public protected(set) string $instanceId;
+
+ public protected(set) string $executionId;
+
+ public static function from(string $instanceId, string $executionId): static
+ {
+ return static::fromArgs(instanceId: $instanceId, executionId: $executionId);
+ }
public function __toString(): string
{
diff --git a/src/State/Serializer.php b/src/State/Serializer.php
index 8587a780..0e58771f 100644
--- a/src/State/Serializer.php
+++ b/src/State/Serializer.php
@@ -38,11 +38,7 @@ public static function serialize(mixed $value, array $scopes = []): array
return self::get()->serialize($value, 'array', scopes: $scopes);
}
if (is_array($value)) {
- $result = [];
- foreach ($value as $k => $v) {
- $result[$k] = self::serialize($v, $scopes);
- }
- return $result;
+ return array_map(static fn($v) => self::serialize($v, $scopes), $value);
}
if (is_scalar($value) || $value === null) {
return compact('value');
@@ -53,13 +49,13 @@ public static function serialize(mixed $value, array $scopes = []): array
public static function get(): Serde
{
- return self::$serializer ??= new SerdeCommon();
+ return self::$serializer ??= new SerdeCommon(handlers: [new Exporter()]);
}
/**
* @template T
- * @param array $value
- * @param class-string $type
+ *
+ * @param class-string $type
* @return T
*/
public static function deserialize(array $value, string $type): mixed
diff --git a/src/Testing/DummyOrchestrationContext.php b/src/Testing/DummyOrchestrationContext.php
index 49831b98..9b4453e2 100644
--- a/src/Testing/DummyOrchestrationContext.php
+++ b/src/Testing/DummyOrchestrationContext.php
@@ -1,4 +1,5 @@
*/
@@ -70,7 +73,15 @@ class DummyOrchestrationContext implements OrchestrationContextInterface
public function __construct(public mixed $orchestration, private array $input)
{
- $this->status = new Status(new DateTimeImmutable(), '', SerializedArray::fromArray($input), StateId::fromInstance(new OrchestrationInstance('test', 'test')), new DateTimeImmutable(), null, RuntimeStatus::Running);
+ $this->status = new Status(
+ new DateTimeImmutable(),
+ '',
+ SerializedArray::fromArray($input),
+ StateId::fromInstance(OrchestrationInstance('test', 'test')),
+ new DateTimeImmutable(),
+ null,
+ RuntimeStatus::Running,
+ );
}
public function handleActivities(ActivityMock ...$activities): void
@@ -136,7 +147,7 @@ public function entityOp(EntityId|string $id, Closure $operation): mixed
}
$name = $type->getName();
- if (! interface_exists($name)) {
+ if (!interface_exists($name)) {
throw new LogicException('Unable to load interface: ' . $name);
}
@@ -156,7 +167,7 @@ public function entityOp(EntityId|string $id, Closure $operation): mixed
throw new LogicException('Did not call an operation');
}
- $entityId = $id instanceof EntityId ? $id : new EntityId($name, $id);
+ $entityId = $id instanceof EntityId ? $id : EntityId($name, $id);
if ($returns) {
return $this->waitOne($this->callEntity($entityId, $operationName, $arguments));
@@ -177,8 +188,10 @@ public function callEntity(
string $operation,
array $args = [],
): DurableFuture {
- return ($this->entities[$entityId->name] ?? throw new LogicException('Failed to find registered entity: ' . $entityId->name))
- ->mock->{$operation}(...$args);
+ return ($this->entities[$entityId->name] ??
+ throw new LogicException('Failed to find registered entity: ' . $entityId->name))->mock->{$operation}(
+ ...$args,
+ );
}
public function signalEntity(
@@ -186,8 +199,10 @@ public function signalEntity(
string $operation,
array $args = [],
): void {
- ($this->entities[$entityId->name] ?? throw new LogicException('Failed to find registered entity: ' . $entityId->name))
- ->mock->{$operation}(...$args);
+ ($this->entities[$entityId->name] ??
+ throw new LogicException('Failed to find registered entity: ' . $entityId->name))->mock->{$operation}(
+ ...$args,
+ );
}
public function isLockedOwned(EntityId $entityId): bool
@@ -294,13 +309,11 @@ public function createInterval(
?int $seconds = null,
?int $microseconds = null,
): DateInterval {
- if (
- empty(
- array_filter(
- compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'),
- )
+ if (empty(
+ array_filter(
+ compact('years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'microseconds'),
)
- ) {
+ )) {
throw new LogicException('At least one interval part must be specified');
}
@@ -345,7 +358,7 @@ public function waitAll(DurableFuture ...$tasks): array
{
$results = [];
foreach ($tasks as $task) {
- if (! $task->future->isComplete()) {
+ if (!$task->future->isComplete()) {
throw new LogicException('Not all futures are completed');
}
$results[] = $task->getResult();
@@ -359,11 +372,11 @@ public function createEntityProxy(
?EntityId $entityId = null,
): object {
if ($entityId === null) {
- $entityId = new EntityId($className, $this->newGuid());
+ $entityId = EntityId($className, $this->newGuid());
}
$class = new ReflectionClass($className);
- if (! $class->isInterface()) {
+ if (!$class->isInterface()) {
throw new LogicException('Only interfaces can be proxied');
}
diff --git a/src/functions.php b/src/functions.php
new file mode 100644
index 00000000..4789fcf7
--- /dev/null
+++ b/src/functions.php
@@ -0,0 +1,25 @@
+ $name
+ */
+function EntityId(string $name, string $id): EntityId
+{
+ return EntityId::from($name, $id);
+}
+
+/**
+ * @template T
+ * @param class-string $instanceId
+ */
+function OrchestrationInstance(string $instanceId, string $executionId): OrchestrationInstance
+{
+ return OrchestrationInstance::from($instanceId, $executionId);
+}
diff --git a/tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap b/tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap
new file mode 100644
index 00000000..36921708
--- /dev/null
+++ b/tests/.pest/snapshots/Feature/ClientProxyTest/it_generates_a_client_proxy_correctly.snap
@@ -0,0 +1,22 @@
+
+
+class __ClientProxy_orchProxy implements orchProxy {
+ public function __construct(private mixed $source) {}
+ public string $prop {
+get {
+ throw new Bottledcode\DurablePhp\Proxy\ImpureException();
+}
+set {
+ $this->source->__setProp($value);
+}
+}
+ public function callExample(): string {
+ throw new Bottledcode\DurablePhp\Proxy\ImpureException();
+}
+public function signalExample(int $a): void {
+ throw new Bottledcode\DurablePhp\Proxy\ImpureException();
+}
+public function pureExample(int|float $number): string {
+ return $this->source->pureExample(...func_get_args());
+}
+}
\ No newline at end of file
diff --git a/tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap b/tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap
new file mode 100644
index 00000000..d49ac3ab
--- /dev/null
+++ b/tests/.pest/snapshots/Feature/OrchestratorProxyTest/it_generates_a_proxy_correctly.snap
@@ -0,0 +1,22 @@
+
+
+class __OrchestratorProxy_orchProxy implements orchProxy {
+ public function __construct(private \Bottledcode\DurablePhp\OrchestrationContextInterface $context, private \Bottledcode\DurablePhp\State\EntityId $id) {}
+ public string $prop {
+get {
+ return $this->context->waitOne($this->context->callEntity($this->id, "\$prop::get", []));
+}
+set {
+ $this->context->waitOne($this->context->callEntity($this->id, "\$prop::set", [$value]));
+}
+}
+ public function callExample(): string {
+ return $this->context->waitOne($this->context->callEntity($this->id, "callExample", func_get_args()));
+}
+public function signalExample(int $a): void {
+ $this->context->signalEntity($this->id, "signalExample", func_get_args());
+}
+public function pureExample(int|float $number): string {
+ return $this->context->waitOne($this->context->callEntity($this->id, "pureExample", func_get_args()));
+}
+}
\ No newline at end of file
diff --git a/tests/ClientTestCli.php b/tests/ClientTestCli.php
index 7d13521e..15c73142 100644
--- a/tests/ClientTestCli.php
+++ b/tests/ClientTestCli.php
@@ -1,4 +1,5 @@
toString(),
);
$client->raiseEvent($orchestrationInstance, 'event', ['data']);
-$client->waitForCompletion($orchestrationInstance, new TimeoutCancellation(hours(2)->as(TimeUnit::Seconds)));
+$client->waitForCompletion($orchestrationInstance, new TimeoutCancellation(hours(2)->as(Unit::Seconds)));
var_dump($client->getStatus($orchestrationInstance));
diff --git a/tests/Feature/ClientProxyTest.php b/tests/Feature/ClientProxyTest.php
index c76a05f3..200f011c 100644
--- a/tests/Feature/ClientProxyTest.php
+++ b/tests/Feature/ClientProxyTest.php
@@ -1,4 +1,5 @@
generate(orchProxy::class);
- expect($proxy)->toBe(
- <<<'EOT'
-
-
-class __ClientProxy_orchProxy implements orchProxy {
- public function __construct(private mixed $source) {}
- public function callExample(): string {
- throw new Bottledcode\DurablePhp\Proxy\ImpureException();
-}
-public function signalExample(int $a): void {
- throw new Bottledcode\DurablePhp\Proxy\ImpureException();
-}
-public function pureExample(int|float $number): string {
- return $this->source->pureExample(...func_get_args());
-}
-}
-EOT,
- );
+ expect($proxy)->toMatchSnapshot();
});
it('is actually callable', function (): void {
$generator = new ClientProxy();
$proxy = $generator->generate(orchProxy::class);
eval($proxy);
- $instance = new class () {
+ $instance = new class {
+ public string $prop = 'test';
+
public function pureExample(int|float $number): string
{
return "Hello {$number}";
}
};
$proxy = new __ClientProxy_orchProxy($instance);
- expect($proxy->pureExample(1))->toBe('Hello 1')
- ->and(fn() => $proxy->signalExample(1))->toThrow(\Bottledcode\DurablePhp\Proxy\ImpureException::class)
- ->and(fn() => $proxy->callExample())->toThrow(\Bottledcode\DurablePhp\Proxy\ImpureException::class);
+ expect($proxy->pureExample(1))
+ ->toBe('Hello 1')->and(fn() => $proxy->signalExample(1))->toThrow(
+ ImpureException::class,
+ )->and(fn() => $proxy->callExample())->toThrow(ImpureException::class);
});
diff --git a/tests/Feature/OrchestratorProxyTest.php b/tests/Feature/OrchestratorProxyTest.php
index 18aa44e2..fee55f0f 100644
--- a/tests/Feature/OrchestratorProxyTest.php
+++ b/tests/Feature/OrchestratorProxyTest.php
@@ -1,4 +1,5 @@
generate(orchProxy::class);
- expect($proxy)->toBe(
- <<<'EOT'
-
-
-class __OrchestratorProxy_orchProxy implements orchProxy {
- public function __construct(private \Bottledcode\DurablePhp\OrchestrationContextInterface $context, private \Bottledcode\DurablePhp\State\EntityId $id) {}
- public function callExample(): string {
- return $this->context->waitOne($this->context->callEntity($this->id, "callExample", func_get_args()));
-}
-public function signalExample(int $a): void {
- $this->context->signalEntity($this->id, "signalExample", func_get_args());
-}
-public function pureExample(int|float $number): string {
- return $this->context->waitOne($this->context->callEntity($this->id, "pureExample", func_get_args()));
-}
-}
-EOT,
- );
+ expect($proxy)->toMatchSnapshot();
});
it('actually works', function (): void {
@@ -71,9 +61,8 @@ public function pureExample(int|float $number): string {
new DurableFuture(new DeferredFuture()),
);
$context->shouldReceive('signalEntity')->andReturn('signal');
- $proxy = new __OrchestratorProxy_orchProxy($context, new EntityId('test', 'test'));
+ $proxy = new __OrchestratorProxy_orchProxy($context, EntityId('test', 'test'));
- expect($proxy->callExample())->toBe('waited')
- ->and($proxy->pureExample(1))->toBe('waited')
- ->and($proxy->signalExample(1))->toBe(null);
+ expect($proxy->callExample())
+ ->toBe('waited')->and($proxy->pureExample(1))->toBe('waited')->and($proxy->signalExample(1))->toBe(null);
});
diff --git a/tests/PerformanceTests/Bank/BankTransaction.php b/tests/PerformanceTests/Bank/BankTransaction.php
index a974a405..4ca61e50 100644
--- a/tests/PerformanceTests/Bank/BankTransaction.php
+++ b/tests/PerformanceTests/Bank/BankTransaction.php
@@ -25,7 +25,8 @@
namespace Bottledcode\DurablePhp\Tests\PerformanceTests\Bank;
use Bottledcode\DurablePhp\OrchestrationContextInterface;
-use Bottledcode\DurablePhp\State\EntityId;
+
+use function Bottledcode\DurablePhp\EntityId;
class BankTransaction
{
@@ -36,13 +37,13 @@ public function __invoke(OrchestrationContextInterface $context)
// generate the source account
$sourceId = "src{$target}";
- $sourceEntity = new EntityId(Account::class, $sourceId);
+ $sourceEntity = EntityId(Account::class, $sourceId);
// generate the destination account
$destinationId = "dst{$target}";
- $destinationEntity = new EntityId(Account::class, $destinationId);
+ $destinationEntity = EntityId(Account::class, $destinationId);
$feeId = "fee{$target}";
- $feeEntity = new EntityId(Account::class, $feeId);
+ $feeEntity = EntityId(Account::class, $feeId);
// the amount to transfer
$transferAmount = 1000;
diff --git a/tests/PerformanceTests/PerformanceClient.php b/tests/PerformanceTests/PerformanceClient.php
index 827c1967..fd693ecc 100644
--- a/tests/PerformanceTests/PerformanceClient.php
+++ b/tests/PerformanceTests/PerformanceClient.php
@@ -26,8 +26,6 @@
use Bottledcode\DurablePhp\DurableClient;
use Bottledcode\DurablePhp\DurableLogger;
-use Bottledcode\DurablePhp\State\EntityId;
-use Bottledcode\DurablePhp\State\OrchestrationInstance;
use Bottledcode\DurablePhp\Tests\Common\LauncherEntity;
use Bottledcode\DurablePhp\Tests\PerformanceTests\HelloCities\HelloSequence;
use Bottledcode\DurablePhp\Tests\StopWatch;
@@ -35,6 +33,8 @@
use function Amp\async;
use function Amp\delay;
use function Amp\Future\await;
+use function Bottledcode\DurablePhp\EntityId;
+use function Bottledcode\DurablePhp\OrchestrationInstance;
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/report.php';
@@ -50,11 +50,12 @@
$numberToLaunch = (getenv('ACTIVITY_COUNT') ?: 1000) / 200;
$numberLaunchers = 200;
for ($i = 0; $i < $numberLaunchers; $i++) {
- async(fn() => $client->signalEntity(
- new EntityId(LauncherEntity::class, $i),
- 'launch',
- ['orchestration' => HelloSequence::class, 'number' => $numberToLaunch, 'offset' => $i * $numberToLaunch],
- ));
+ async(fn()
+ => $client->signalEntity(
+ EntityId(LauncherEntity::class, $i),
+ 'launch',
+ ['orchestration' => HelloSequence::class, 'number' => $numberToLaunch, 'offset' => $i * $numberToLaunch],
+ ));
}
delay(1);
@@ -63,7 +64,11 @@
$ids = array_chunk($ids, 50);
foreach ($ids as $num => $chunk) {
- $getters = array_map(static fn($id) => async(fn() => $client->waitForCompletion(new OrchestrationInstance(HelloSequence::class, $id))), $chunk);
+ $getters = array_map(
+ static fn($id)
+ => async(fn() => $client->waitForCompletion(OrchestrationInstance(HelloSequence::class, $id))),
+ $chunk,
+ );
$logger->alert(sprintf('Waiting for chunk %d of %d', $num, count($ids)));
await($getters);
}
diff --git a/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php b/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php
index b9bbf82c..5e705aa6 100644
--- a/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php
+++ b/tests/PerformanceTests/src/Benchmarks/Bank/BankTransaction.php
@@ -3,7 +3,8 @@
namespace Bottledcode\DurablePhp\Tests\PerformanceTests\src\Benchmarks\Bank;
use Bottledcode\DurablePhp\Attributes\Orchestration;
-use Bottledcode\DurablePhp\EntityId;
+
+use function Bottledcode\DurablePhp\EntityId;
#[Orchestration]
function BankTransaction($context): bool
@@ -11,10 +12,10 @@ function BankTransaction($context): bool
$pair = $context->getInput();
$sourceId = sprintf('src%d-!-%d', $pair, ($pair + 1) % 32);
- $sourceEntity = new EntityId(AccountInterface::class, $sourceId);
+ $sourceEntity = EntityId(AccountInterface::class, $sourceId);
$destinationId = sprintf('dst%d-!%d', $pair, ($pair + 2) % 32);
- $destinationEntity = new EntityId(AccountInterface::class, $destinationId);
+ $destinationEntity = EntityId(AccountInterface::class, $destinationId);
$transferAmount = 1000;
$sourceProxy = $context->createProxy(AccountInterface::class, $sourceEntity);
diff --git a/tests/Pest.php b/tests/Pest.php
index dd073d77..545a951f 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -59,16 +59,17 @@
use Bottledcode\DurablePhp\Proxy\SpyProxy;
use Bottledcode\DurablePhp\State\AbstractHistory;
use Bottledcode\DurablePhp\State\EntityHistory;
-use Bottledcode\DurablePhp\State\EntityId;
use Bottledcode\DurablePhp\State\EntityState;
use Bottledcode\DurablePhp\State\Ids\StateId;
use Bottledcode\DurablePhp\State\OrchestrationHistory;
-use Bottledcode\DurablePhp\State\OrchestrationInstance;
use Bottledcode\DurablePhp\State\RuntimeStatus;
use Bottledcode\DurablePhp\State\Status;
use Bottledcode\DurablePhp\Task;
use DI\Container;
+use function Bottledcode\DurablePhp\EntityId;
+use function Bottledcode\DurablePhp\OrchestrationInstance;
+
$_SERVER['SERVER_PROTOCOL'] = 'DPHP/1.0';
expect()->extend('toBeOne', fn() => $this->toBe(1));
@@ -77,7 +78,10 @@
/** @var Status $otherStatus */
$otherStatus = $this->value->getStatus();
- return expect($otherStatus->runtimeStatus)->toBe($status, "Expected status {$status->name} but got {$otherStatus->runtimeStatus->name}");
+ return expect($otherStatus->runtimeStatus)->toBe(
+ $status,
+ "Expected status {$status->name} but got {$otherStatus->runtimeStatus->name}",
+ );
});
expect()->extend('toHaveOutput', fn(mixed $output) => expect(getStatusOutput($this->value))->toBe($output));
@@ -166,8 +170,8 @@ public function fire(Event ...$events): array
function getEntityHistory(?EntityState $withState = null): EntityHistory
{
static $id = 0;
- $withState ??= new class () extends EntityState {};
- $entityId = new EntityId('test', $id++);
+ $withState ??= new class extends EntityState {};
+ $entityId = EntityId('test', $id++);
$history = new EntityHistory(StateId::fromEntityId($entityId), new DurableLogger(), new Provenance('', []));
$reflector = new ReflectionClass($history);
$reflector->getProperty('state')->setValue($history, $withState);
@@ -196,7 +200,11 @@ function getOrchestration(
$instance => $orchestration,
],
);
- $history = new OrchestrationHistory(StateId::fromInstance(new OrchestrationInstance($instance, $id)), new DurableLogger(), new Provenance('', []));
+ $history = new OrchestrationHistory(
+ StateId::fromInstance(OrchestrationInstance($instance, $id)),
+ new DurableLogger(),
+ new Provenance('', []),
+ );
$history->setContainer($container);
$startupEvent ??= StartExecution::asParent($input, []);
$startupEvent = WithOrchestration::forInstance($history->id, $startupEvent);
diff --git a/tests/Unit/ActivityHistoryTest.php b/tests/Unit/ActivityHistoryTest.php
index c14740b7..29dc0df8 100644
--- a/tests/Unit/ActivityHistoryTest.php
+++ b/tests/Unit/ActivityHistoryTest.php
@@ -31,11 +31,12 @@
use Bottledcode\DurablePhp\Events\WithActivity;
use Bottledcode\DurablePhp\Glue\Provenance;
use Bottledcode\DurablePhp\State\ActivityHistory;
-use Bottledcode\DurablePhp\State\EntityId;
use Bottledcode\DurablePhp\State\Ids\StateId;
use DI\Container;
use Ramsey\Uuid\Uuid;
+use function Bottledcode\DurablePhp\EntityId;
+
function activity(bool $fail): void
{
if ($fail) {
@@ -50,16 +51,16 @@ function activity(bool $fail): void
it('real: fails on an exception', function (): void {
$history = new ActivityHistory(StateId::fromActivityId(Uuid::uuid7()), null, new Provenance('', []));
$event = AwaitResult::forEvent(
- StateId::fromEntityId(new EntityId('test', 'test')),
+ StateId::fromEntityId(EntityId('test', 'test')),
WithActivity::forEvent(Uuid::uuid7(), ScheduleTask::forName(__NAMESPACE__ . '\activity', [true])),
);
$result1 = processEvent($event, $history->applyScheduleTask(...));
expect($result1)->toHaveCount(1)->and($result1[0]->getInnerEvent())->toBeInstanceOf(TaskFailed::class);
$result2 = processEvent($event, $history->applyScheduleTask(...));
- expect($result2)->toHaveCount(1)
- ->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskFailed::class)
- ->and(current($result1))->toEqual(current($result2));
+ expect($result2)
+ ->toHaveCount(1)->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskFailed::class)->and(current($result1))
+ ->toEqual(current($result2));
});
it('succeeds on no exception', function (): void {
@@ -67,14 +68,17 @@ function activity(bool $fail): void
$container = new Container([__NAMESPACE__ . '\activity' => activity(...)]);
$history->setContainer($container);
$event = AwaitResult::forEvent(
- StateId::fromEntityId(new EntityId('test', 'test')),
+ StateId::fromEntityId(EntityId('test', 'test')),
WithActivity::forEvent(Uuid::uuid7(), ScheduleTask::forName(__NAMESPACE__ . '\activity', [false])),
);
$result1 = processEvent($event, $history->applyScheduleTask(...));
- expect($result1)->toHaveCount(1)->and($result1[0]->getInnerEvent()->getInnerEvent())->toBeInstanceOf(TaskCompleted::class);
+ expect($result1)->toHaveCount(1)->and($result1[0]->getInnerEvent()->getInnerEvent())->toBeInstanceOf(
+ TaskCompleted::class,
+ );
$result2 = processEvent($event, $history->applyScheduleTask(...));
- expect($result2)->toHaveCount(1)
- ->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskCompleted::class)
- ->and(current($result1))->toEqual(current($result2));
+ expect($result2)
+ ->toHaveCount(1)->and($result2[0]->getInnerEvent())->toBeInstanceOf(TaskCompleted::class)->and(
+ current($result1),
+ )->toEqual(current($result2));
});
diff --git a/tests/Unit/EntityHistoryTest.php b/tests/Unit/EntityHistoryTest.php
index 11587f58..c27be01e 100644
--- a/tests/Unit/EntityHistoryTest.php
+++ b/tests/Unit/EntityHistoryTest.php
@@ -1,4 +1,5 @@
applyRaiseEvent(...),
);
- expect($unlockResult)->toContain($waiting)
- ->and($called)->toBe(1);
+ expect($unlockResult)
+ ->toContain($waiting)->and($called)->toBe(1);
});
it('properly locks in a chain', function (): void {
@@ -135,8 +137,8 @@ public function signal(): void
},
);
- $owner = StateId::fromInstance(new OrchestrationInstance('owner', 'owner'));
- $other = StateId::fromInstance(new OrchestrationInstance('other', 'other'));
+ $owner = StateId::fromInstance(OrchestrationInstance('owner', 'owner'));
+ $other = StateId::fromInstance(OrchestrationInstance('other', 'other'));
$otherEntity = getEntityHistory();
@@ -168,8 +170,8 @@ public function signal(): void
// send the first lock notification in the chain
$firstResult = processEvent($firstLock, $otherEntity->applyRaiseEvent(...));
- expect($firstResult)->toHaveCount(3)
- ->and($firstResult[0]->innerEvent->target->id)->toBe($history->id->id);
+ expect($firstResult)
+ ->toHaveCount(3)->and($firstResult[0]->innerEvent->target->id)->toBe($history->id->id);
// send a signal to be run once the lock is complete
$locked = processEvent($actualEvent, $history->applyRaiseEvent(...));
@@ -177,8 +179,8 @@ public function signal(): void
// complete the lock sequence
$secondResult = processEvent($firstResult[0], $history->applyRaiseEvent(...));
- expect($secondResult)->toHaveCount(3)
- ->and($secondResult[0])->toBeInstanceOf(WithEntity::class);
+ expect($secondResult)
+ ->toHaveCount(3)->and($secondResult[0])->toBeInstanceOf(WithEntity::class);
// process the actual event earlier
$finalResult = processEvent($secondResult[0], $history->applyRaiseEvent(...));
diff --git a/tests/Unit/EventDescriptionTest.php b/tests/Unit/EventDescriptionTest.php
new file mode 100644
index 00000000..be1d2579
--- /dev/null
+++ b/tests/Unit/EventDescriptionTest.php
@@ -0,0 +1,342 @@
+toString());
+ }
+
+ public function __toString(): string
+ {
+ return 'SimpleEvent()';
+ }
+}
+
+#[NeedsTarget(Operation::Signal)]
+class EventWithTargetAttribute extends Event
+{
+ public function __construct(string $eventId = '')
+ {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function __toString(): string
+ {
+ return 'EventWithTargetAttribute()';
+ }
+}
+
+#[NeedsSource(Operation::Call)]
+class EventWithSourceAttribute extends Event
+{
+ public function __construct(string $eventId = '')
+ {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function __toString(): string
+ {
+ return 'EventWithSourceAttribute()';
+ }
+}
+
+class EventWithReplyTo extends Event implements ReplyToInterface
+{
+ public function __construct(string $eventId, private string $replyToId)
+ {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function getReplyTo(): StateId
+ {
+ // In a real test, we'd need to return an actual StateId
+ // For our test purposes, we'll mock this behavior
+ return StateId::fromString($this->replyToId);
+ }
+
+ public function __toString(): string
+ {
+ return 'EventWithReplyTo()';
+ }
+}
+
+class EventWithTarget extends Event implements StateTargetInterface
+{
+ public function __construct(
+ string $eventId,
+ private string $targetId,
+ ) {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function getTarget(): StateId
+ {
+ // In a real test, we'd need to return an actual StateId
+ // For our test purposes, we'll mock this behavior
+ return StateId::fromString($this->targetId);
+ }
+
+ public function __toString(): string
+ {
+ return 'EventWithTarget()';
+ }
+}
+
+class MockExternalEvent extends Event implements External
+{
+ public string $externalData = 'external data';
+
+ public function __construct(string $eventId = '')
+ {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function __toString(): string
+ {
+ return 'MockExternalEvent()';
+ }
+}
+
+class MockWrapperEvent extends Event implements HasInnerEventInterface
+{
+ public function __construct(string $eventId = '', private Event $inner = new SimpleEvent())
+ {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function getInnerEvent(): Event
+ {
+ return $this->inner;
+ }
+
+ public function __toString(): string
+ {
+ return 'MockWrapperEvent(' . $this->inner . ')';
+ }
+}
+
+// Tests for EventDescription constructor and describe method
+test('EventDescription constructor with simple event', function (): void {
+ $event = new SimpleEvent('test-id');
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('test-id')
+ ->and($description->innerEvent)->toBe($event)
+ ->and($description->locks)->toBeFalse()
+ ->and($description->isPoisoned)->toBeFalse()
+ ->and($description->replyTo)->toBeNull()
+ ->and($description->scheduledAt)->toBeNull()
+ ->and($description->destination)->toBeNull()
+ ->and($description->targetType)->toBe(TargetType::None)
+ ->and($description->sourceOperations)->toBeEmpty()
+ ->and($description->targetOperations)->toBeEmpty();
+});
+
+test('EventDescription constructor with event that has target attribute', function (): void {
+ $event = new EventWithTargetAttribute('test-id');
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('test-id')
+ ->and($description->innerEvent)->toBe($event)
+ ->and($description->targetOperations)->toHaveCount(1)
+ ->and($description->targetOperations[0])->toBe(Operation::Signal);
+});
+
+test('EventDescription constructor with event that has source attribute', function (): void {
+ $event = new EventWithSourceAttribute('test-id');
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('test-id');
+ expect($description->innerEvent)->toBe($event);
+ expect($description->sourceOperations)->toHaveCount(1);
+ expect($description->sourceOperations[0])->toBe(Operation::Call);
+});
+
+test('EventDescription constructor with event that implements ReplyToInterface', function (): void {
+ $event = AwaitResult::forEvent(StateId::fromString('orchestration:instance:reply-to-id'), new SimpleEvent('test-id'));
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('test-id');
+ expect($description->replyTo)->not()->toBeNull();
+ expect((string) $description->replyTo)->toBe('orchestration:instance:reply-to-id');
+});
+
+test('EventDescription constructor with event that implements StateTargetInterface', function (): void {
+ $event = WithOrchestration::forInstance(StateId::fromString('activity:target-id'), new SimpleEvent('test-id'));
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('test-id');
+ expect($description->destination)->not()->toBeNull();
+ expect((string) $description->destination)->toBe('activity:target-id');
+ expect($description->targetType)->toBe(TargetType::Activity);
+});
+
+test('EventDescription constructor with WithDelay event', function (): void {
+ $innerEvent = StartOrchestration::forInstance(OrchestrationInstance('instance', 'inner-id'));
+ $fireAt = new DateTimeImmutable('2023-01-01 12:00:00');
+ $event = WithDelay::forEvent($fireAt, $innerEvent);
+ $description = new EventDescription($event);
+
+ expect($description->destination)->toBe(StateId::fromString('orchestration:instance:inner-id'));
+ expect($description->scheduledAt)->toBe($fireAt);
+ expect($description->innerEvent)->toBe($innerEvent->getInnerEvent());
+});
+
+test('EventDescription constructor with WithLock event', function (): void {
+ $innerEvent = new SimpleEvent('inner-id');
+ $owner = StateId::fromString('orchestration:instance:owner-id');
+ $target = StateId::fromString('entity:target-id');
+ $event = WithLock::onEntity($owner, $innerEvent, $target);
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('inner-id');
+ expect($description->locks)->toBeTrue();
+ expect($description->innerEvent)->toBe($innerEvent);
+ expect($description->targetOperations)->toHaveCount(1);
+ expect($description->targetOperations[0])->toBe(Operation::Lock);
+});
+
+test('EventDescription constructor with PoisonPill event', function (): void {
+ $event = PoisonPill::digest();
+ $description = new EventDescription($event);
+
+ expect($description->isPoisoned)->toBeTrue();
+});
+
+test('EventDescription constructor with External event', function (): void {
+ $event = new MockExternalEvent('test-id');
+ $description = new EventDescription($event);
+
+ expect($description->eventId)->toBe('test-id');
+ expect($description->meta)->toBeArray();
+ expect($description->meta)->toHaveKey('externalData');
+ expect($description->meta['externalData'])->toBe('external data');
+});
+
+test('EventDescription constructor with nested events', function (): void {
+ $innerEvent = new EventWithTargetAttribute('inner-id');
+ $wrapperEvent = new MockWrapperEvent('wrapper-id', $innerEvent);
+ $description = new EventDescription($wrapperEvent);
+
+ expect($description->eventId)->toBe('wrapper-id');
+ expect($description->innerEvent)->toBe($innerEvent);
+ expect($description->targetOperations)->toHaveCount(1);
+ expect($description->targetOperations[0])->toBe(Operation::Signal);
+});
+
+// Tests for serialization/deserialization methods
+test('toStream method', function (): void {
+ $event = new SimpleEvent('test-id');
+ $description = new EventDescription($event);
+
+ $stream = $description->toStream();
+ $stream = json_decode($stream, true);
+ $result = EventDescription::fromStream($stream['event']);
+
+ // hack around serialization of timestamps
+ $description = new EventDescription($event->with(timestamp: $result->event->timestamp));
+
+ expect($result)->toEqual($description);
+});
+
+test('toJson method', function (): void {
+ $event = new SimpleEvent('test-id');
+ $description = new EventDescription($event);
+
+ $json = $description->toJson();
+ $result = EventDescription::fromJson($json);
+ // hack around serialization of timestamps
+ $description = new EventDescription($event->with(timestamp: $result->event->timestamp));
+ expect($result)->toEqual($description);
+});
+
+// Edge cases
+test('EventDescription handles multiple attributes of the same type', function (): void {
+ #[NeedsTarget(Operation::Signal)]
+ #[NeedsTarget(Operation::Call)]
+ class EventWithMultipleAttributes extends Event
+ {
+ public function __construct(string $eventId = '')
+ {
+ parent::__construct($eventId ?: Uuid::uuid7()->toString());
+ }
+
+ public function __toString(): string
+ {
+ return 'EventWithMultipleAttributes()';
+ }
+ }
+
+ $event = new EventWithMultipleAttributes('test-id');
+ $description = new EventDescription($event);
+
+ expect($description->targetOperations)->toHaveCount(2);
+ expect($description->targetOperations)->toContain(Operation::Signal);
+ expect($description->targetOperations)->toContain(Operation::Call);
+});
+
+test('EventDescription handles different target types', function (): void {
+ $testCases = [
+ ['isActivity' => true, 'expected' => TargetType::Activity],
+ ['isOrchestration' => true, 'expected' => TargetType::Orchestration],
+ ['isEntity' => true, 'expected' => TargetType::Entity],
+ ];
+
+ foreach ($testCases as $case) {
+ $targetId = StateId::fromString(match ($case) {
+ [...$case, 'isActivity' => true] => 'activity:id',
+ [...$case, 'isOrchestration' => true] => 'orchestration:instance:id',
+ [...$case, 'isEntity' => true] => 'entity:id',
+ });
+ $event = new WithOrchestration('test-id', $targetId, TaskFailed::forTask('123', 'test'));
+ $description = new EventDescription($event);
+
+ expect($description->targetType)->toBe($case['expected']);
+ }
+});
diff --git a/tests/Unit/LockIntegrationTest.php b/tests/Unit/LockIntegrationTest.php
index 37c98e91..47b43ac8 100644
--- a/tests/Unit/LockIntegrationTest.php
+++ b/tests/Unit/LockIntegrationTest.php
@@ -1,4 +1,5 @@
lockEntity(new EntityId('test', 'test'));
+ $lock = $context->lockEntity(EntityId('test', 'test'));
expect($lock->isLocked())->toBeTrue();
- $result = $context->callEntity(new EntityId('test', 'test'), 'test');
+ $result = $context->callEntity(EntityId('test', 'test'), 'test');
$result = $context->waitOne($result);
expect($result)->toBe('hello world');
$lock->unlock();
@@ -39,12 +41,14 @@
return $result;
}, [], $nextEvent);
- $entity = getEntityHistory(new class () extends EntityState {
- public function test()
- {
- return 'hello world';
- }
- });
+ $entity = getEntityHistory(
+ new class extends EntityState {
+ public function test()
+ {
+ return 'hello world';
+ }
+ },
+ );
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
$instance->resetState();
diff --git a/tests/Unit/OrchestrationHistoryTest.php b/tests/Unit/OrchestrationHistoryTest.php
index 4ef7e8a3..93e9e1f9 100644
--- a/tests/Unit/OrchestrationHistoryTest.php
+++ b/tests/Unit/OrchestrationHistoryTest.php
@@ -1,4 +1,5 @@
true, [], $nextEvent);
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Completed);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed);
});
class SerializedType
@@ -62,10 +64,13 @@ public function entry(string $test, SerializedType $type): string
}
};
- $instance = getOrchestration(id: 'test', orchestration: $orchestration, input: ['test' => 'hello world', 'type' => Serializer::serialize(new SerializedType('test'))], nextEvent: $nextEvent);
+ $instance = getOrchestration(id: 'test', orchestration: $orchestration, input: [
+ 'test' => 'hello world',
+ 'type' => Serializer::serialize(new SerializedType('test')),
+ ], nextEvent: $nextEvent);
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Completed);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed);
});
it('example: can handle oop orchestration', function (): void {
@@ -90,11 +95,11 @@ public function entry(string $test, SerializedType $type): string
fn() => true,
[],
$nextEvent,
- StartExecution::asChild(new OrchestrationInstance('parent', 'parent'), [], []),
+ StartExecution::asChild(OrchestrationInstance('parent', 'parent'), [], []),
);
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
- expect($result)->toHaveCount(1)
- ->and($instance)->toHaveStatus(RuntimeStatus::Completed);
+ expect($result)
+ ->toHaveCount(1)->and($instance)->toHaveStatus(RuntimeStatus::Completed);
});
it('properly delays when using timers', function (): void {
@@ -110,9 +115,9 @@ public function entry(string $test, SerializedType $type): string
expect($timer)->toHaveCount(1);
$instance->resetState();
$result = processEvent($timer[0], $instance->applyRaiseEvent(...));
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Completed)
- ->and(getStatusOutput($instance))->toBeTrue();
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed)->and(getStatusOutput($instance))
+ ->toBeTrue();
});
it('properly delays when using timers (example)', function (): void {
@@ -142,30 +147,33 @@ public function entry(string $test, SerializedType $type): string
}, [], $nextEvent);
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
$instance->resetState();
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Running);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Running);
$result = processEvent(
- WithOrchestration::forInstance($instance->id, new RaiseEvent('', 'test', SerializedArray::fromArray([])->toArray())),
+ WithOrchestration::forInstance(
+ $instance->id,
+ new RaiseEvent('', 'test', SerializedArray::fromArray([])->toArray()),
+ ),
$instance->applyRaiseEvent(...),
);
$instance->resetState();
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Running);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Running);
$result = processEvent(
WithOrchestration::forInstance($instance->id, new RaiseEvent('', 'test', [])),
$instance->applyRaiseEvent(...),
);
$instance->resetState();
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Running);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Running);
$result = processEvent(
WithOrchestration::forInstance($instance->id, new RaiseEvent('', 'test', [])),
$instance->applyRaiseEvent(...),
);
$instance->resetState();
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Completed)
- ->and(getStatusOutput($instance))->toBeTrue();
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Completed)->and(getStatusOutput($instance))
+ ->toBeTrue();
});
it('can wait for a signal after starting (example)', function (): void {
@@ -186,7 +194,12 @@ public function entry(string $test, SerializedType $type): string
});
it('can call an activity with a successful result', function (): void {
- $instance = getOrchestration('test', fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])), [], $nextEvent);
+ $instance = getOrchestration(
+ 'test',
+ fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])),
+ [],
+ $nextEvent,
+ );
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
$instance->resetState();
@@ -195,27 +208,35 @@ public function entry(string $test, SerializedType $type): string
WithOrchestration::forInstance($instance->id, TaskCompleted::forId($result[0]->eventId, 'pretty colors')),
$instance->applyTaskCompleted(...),
);
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveOutput('pretty colors')
- ->and($instance)->toHaveStatus(RuntimeStatus::Completed);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveOutput('pretty colors')->and($instance)->toHaveStatus(
+ RuntimeStatus::Completed,
+ );
});
it('can call an activity with a successful result (example)', function (): void {
- $instance = fn(OrchestrationContextInterface $context) => $context->waitOne($context->callActivity('test', ['hello world']));
+ $instance = fn(OrchestrationContextInterface $context)
+ => $context->waitOne($context->callActivity('test', ['hello world']));
$context = new DummyOrchestrationContext($instance, []);
$context->handleActivities(new ActivityMock('test', 'pretty colors'));
expect($instance($context))->toBe(['pretty colors']);
});
it('can call an activity with a failed result (example)', function (): void {
- $instance = fn(OrchestrationContextInterface $context) => $context->waitOne($context->callActivity('test', ['hello world']));
+ $instance = fn(OrchestrationContextInterface $context)
+ => $context->waitOne($context->callActivity('test', ['hello world']));
$context = new DummyOrchestrationContext($instance, []);
$context->handleActivities(new ActivityMock('test', new Exception('hello world')));
expect(fn() => $instance($context))->toThrow(Exception::class, 'hello world');
});
it('can call an activity with a failed result', function (): void {
- $instance = getOrchestration('test', fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])), [], $nextEvent);
+ $instance = getOrchestration(
+ 'test',
+ fn(OrchestrationContext $context) => $context->waitOne($context->callActivity('test', ['hello world'])),
+ [],
+ $nextEvent,
+ );
$result = processEvent($nextEvent, $instance->applyStartOrchestration(...));
$instance->resetState();
@@ -224,6 +245,6 @@ public function entry(string $test, SerializedType $type): string
WithOrchestration::forInstance($instance->id, TaskFailed::forTask($result[0]->eventId, 'pretty colors')),
$instance->applyTaskFailed(...),
);
- expect($result)->toBeEmpty()
- ->and($instance)->toHaveStatus(RuntimeStatus::Failed);
+ expect($result)
+ ->toBeEmpty()->and($instance)->toHaveStatus(RuntimeStatus::Failed);
});
diff --git a/tests/Unit/RecordTest.php b/tests/Unit/RecordTest.php
new file mode 100644
index 00000000..3b764434
--- /dev/null
+++ b/tests/Unit/RecordTest.php
@@ -0,0 +1,33 @@
+toBe($record);
+});
+
+it('can serialize an orchestration id', function (): void {
+ $record = OrchestrationInstance('name', 'id');
+ $result = Serializer::serialize($record);
+ $result = Serializer::deserialize($result, OrchestrationInstance::class);
+ expect($result)->toBe($record);
+});
+
+it('can serialize an event', function (): void {
+ $entity = EntityId('name', 'id');
+ $event = WithEntity::forInstance(StateId::fromEntityId($entity), RaiseEvent::forOperation('get', ['test' => 'test']));
+ $result = Serializer::serialize($event);
+ $result = Serializer::deserialize($result, WithEntity::class);
+ expect($result->target)->toBe($event->target);
+});