├── .editorconfig ├── .github └── workflows │ └── php.yml ├── .gitignore ├── AUTHORS ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── phpstan.neon ├── public_html └── index.php ├── src ├── Core │ ├── Capabilities │ │ ├── CoreCapability.php │ │ └── CoreCapability │ │ │ └── CoreType │ │ │ └── CoreEchoMethod.php │ ├── Capability.php │ ├── CapabilityFactory.php │ ├── Controllers │ │ ├── AbstractController.php │ │ ├── AbstractControllerFactory.php │ │ ├── ApiController.php │ │ └── SessionController.php │ ├── Exceptions │ │ ├── MethodInvocationException.php │ │ └── UnknownCapabilityException.php │ ├── Invocation.php │ ├── JsonPointer.php │ ├── Method.php │ ├── Methods │ │ ├── ChangesMethod.php │ │ ├── CopyMethod.php │ │ ├── GetMethod.php │ │ ├── QueryChangesMethod.php │ │ ├── QueryMethod.php │ │ └── SetMethod.php │ ├── README.md │ ├── Request.php │ ├── RequestContext.php │ ├── RequestContextFactory.php │ ├── RequestError.php │ ├── RequestErrors │ │ ├── LimitError.php │ │ ├── NotJsonError.php │ │ ├── NotRequestError.php │ │ └── UnknownCapabilityError.php │ ├── Response.php │ ├── ResultReference.php │ ├── Schemas │ │ ├── DirLoader.php │ │ ├── ValidationException.php │ │ ├── Validator.php │ │ ├── ValidatorFactory.php │ │ ├── ValidatorInterface.php │ │ └── schemas │ │ │ ├── Invocation.json │ │ │ ├── Request.json │ │ │ ├── ResultReference.json │ │ │ └── methods │ │ │ ├── changes.json │ │ │ ├── copy.json │ │ │ ├── get.json │ │ │ ├── query.json │ │ │ ├── queryChanges.json │ │ │ └── set.json │ ├── Session.php │ └── SessionFactory.php └── Mail │ ├── MailCapability.php │ ├── MailCapability │ └── MailboxType │ │ └── MailboxGetMethod.php │ ├── MailInterface.php │ └── README.md └── tests └── Core ├── CapabilityTest.php ├── Controllers ├── ApiControllerTest.php └── SessionControllerTest.php ├── InvocationTest.php ├── JsonPointerTest.php ├── RequestTest.php ├── ResponseTest.php ├── ResultReferenceTest.php ├── SessionTest.php └── Stubs ├── FailingValidatorStub.php ├── PassingValidatorStub.php └── RaisingMethodStub.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.json] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: Lint and unit test the JMAP Proxy 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | pull_request: 7 | branches: [develop] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: "7.3" 20 | extensions: mbstring, ds 21 | tools: composer:v1 22 | 23 | - name: Validate composer.json and composer.lock 24 | run: composer validate 25 | 26 | - name: Cache Composer packages 27 | id: composer-cache 28 | uses: actions/cache@v2 29 | with: 30 | path: vendor 31 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 32 | restore-keys: | 33 | ${{ runner.os }}-php- 34 | 35 | - name: Install dependencies 36 | if: steps.composer-cache.outputs.cache-hit != 'true' 37 | run: composer install --prefer-dist --no-progress --no-suggest 38 | 39 | - name: Run linting and tests 40 | run: | 41 | composer lint 42 | composer test 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Leonard Techel 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Leonard Techel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JMAP Proxy 2 | 3 | The goal of this project is to implement a spec-compliant [JMAP](https://jmap.io/) server in PHP. It should be focussed onto the [Core](https://tools.ietf.org/html/rfc8620) and [Mail](https://tools.ietf.org/html/rfc8621) specifications but is not limited to it. 4 | 5 | The main difficulty with leveraging JMAP to build modern, responsive E-Mail-Clients is that there are no easy to adapt backend implementations: While it makes sense to implement the protocol within existing IMAP servers like Cyrus and Dovecot from a performance point of view, it often prevents existing setups from adopting JMAP as they may not easily switch their IMAP server setup, if they have access to it at all. After all, this also prevents commonly-used self-hosted webmail clients like Roundcube from broadly adopting JMAP. 6 | 7 | By implementing the JMAP layer using PHP, this project makes JMAP more accessible to the community and makes it easy to use the protocol for use-cases outside the focus of a mail server, for example sharing files within a groupware. 8 | 9 | ## Getting started 10 | 11 | There is still a lack of documentation on how to use the code. Until it is done, start by running a local PHP server: 12 | 13 | ``` 14 | php -d opcache.enable=0 -S localhost:9010 -t public_html public_html/index.php 15 | ``` 16 | 17 | See [Core/README.md](src/Core/README.md) for an overview of the internals of JMAP and this library! 18 | 19 | ## Authors 20 | 21 | See [AUTHORS](AUTHORS) 22 | 23 | ## License 24 | 25 | See [LICENSE](LICENSE) 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "barnslig/jmap-proxy", 3 | "description": "Framework to implement a JMAP (RFC 8620) server.", 4 | "type": "project", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Leonard Techel", 9 | "email": "git@barnslig.eu" 10 | } 11 | ], 12 | "repositories": [ 13 | { 14 | "type": "pear", 15 | "url": "https://pear.horde.org" 16 | } 17 | ], 18 | "require-dev": { 19 | "squizlabs/php_codesniffer": "3.*", 20 | "phpunit/phpunit": "^8", 21 | "phpstan/phpstan": "^0.12.26", 22 | "jangregor/phpstan-prophecy": "^0.8.1" 23 | }, 24 | "require": { 25 | "php": ">=7.3", 26 | "ext-mbstring": "*", 27 | "pear-pear.horde.org/horde_imap_client": "^2.30.1@stable", 28 | "pear-pear.horde.org/horde_cache": "^2.5.5@stable", 29 | "league/route": "^4.3", 30 | "laminas/laminas-diactoros": "^2.2", 31 | "laminas/laminas-httphandlerrunner": "^1.1", 32 | "php-ds/php-ds": "^1.2", 33 | "opis/json-schema": "^1.0", 34 | "m1x0n/opis-json-schema-error-presenter": "^0.5.1", 35 | "laminas/laminas-servicemanager": "^3.4", 36 | "psr/http-message": "^1.0", 37 | "psr/http-server-handler": "^1.0", 38 | "psr/http-factory": "^1.0" 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Barnslig\\Jmap\\Tests\\": "tests" 43 | } 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Barnslig\\Jmap\\": "src" 48 | } 49 | }, 50 | "scripts": { 51 | "lint": "phpcs --standard=PSR12 src/ tests/; phpstan analyse -l 8 src/ tests/", 52 | "pretty": "phpcbf --standard=PSR12 src/ tests/", 53 | "test": "phpunit --bootstrap vendor/autoload.php --testdox tests/" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/jangregor/phpstan-prophecy/extension.neon 3 | 4 | parameters: 5 | level: 8 6 | paths: 7 | - src 8 | - tests 9 | ignoreErrors: 10 | - message: '#Method .* has no return typehint specified\.#' 11 | path: tests 12 | - message: '#Method .*Factory::__invoke\(\) has parameter \$options with no value type specified in iterable type array\.#' 13 | path: src/**/*Factory.php 14 | -------------------------------------------------------------------------------- /public_html/index.php: -------------------------------------------------------------------------------- 1 | [ 27 | 'config' => [ 28 | 'session' => [ 29 | 'capabilities' => [ 30 | CoreCapability::class, 31 | MailCapability::class, 32 | ], 33 | ], 34 | 35 | CoreCapability::class => [ 36 | 'maxSizeUpload' => 50000000, 37 | 'maxConcurrentUpload' => 4, 38 | 'maxSizeRequest' => 10000000, 39 | 'maxConcurrentRequests' => 4, 40 | 'maxCallsInRequest' => 16, 41 | 'maxObjectsInGet' => 500, 42 | 'maxObjectsInSet' => 500, 43 | 'collationAlgorithms' => [], 44 | ], 45 | 46 | MailCapability::class => [ 47 | "maxMailboxesPerEmail" => null, 48 | "maxMailboxDepth" => null, 49 | "maxSizeMailboxName" => 100, 50 | "maxSizeAttachmentsPerEmail" => 50000000, 51 | "emailQuerySortOptions" => [], 52 | "mayCreateTopLevelMailbox" => true 53 | ], 54 | ], 55 | ], 56 | 'factories' => [ 57 | // JMAP Core 58 | RequestContext::class => RequestContextFactory::class, 59 | Session::class => SessionFactory::class, 60 | ValidatorInterface::class => ValidatorFactory::class, 61 | 62 | // JMAP Capabilities 63 | CoreCapability::class => CapabilityFactory::class, 64 | MailCapability::class => CapabilityFactory::class, 65 | 66 | // PSR-15 HTTP Controllers 67 | ApiController::class => AbstractControllerFactory::class, 68 | SessionController::class => AbstractControllerFactory::class, 69 | ] 70 | ]); 71 | 72 | 73 | // SETUP ROUTER 74 | $router = new League\Route\Router(); 75 | 76 | $router->get('/.well-known/jmap', [$container->get(SessionController::class), 'handle']); 77 | $router->post('/jmap', [$container->get(ApiController::class), 'handle']); 78 | 79 | 80 | // DISPATCH REQUEST 81 | $request = ServerRequestFactory::fromGlobals(); 82 | 83 | try { 84 | $response = $router->dispatch($request); 85 | (new SapiEmitter())->emit($response); 86 | } catch (\League\Route\Http\Exception\NotFoundException $exception) { 87 | // pass on so php -S can serve static files 88 | return false; 89 | } 90 | -------------------------------------------------------------------------------- /src/Core/Capabilities/CoreCapability.php: -------------------------------------------------------------------------------- 1 | */ 11 | private $options; 12 | 13 | /** 14 | * @param array $options 15 | */ 16 | public function __construct($options = []) 17 | { 18 | $this->options = $options; 19 | } 20 | 21 | public function getCapabilities(): object 22 | { 23 | return (object)$this->options; 24 | } 25 | 26 | public function getMethods(): Map 27 | { 28 | return new Map([ 29 | "Core/echo" => CoreCapability\CoreType\CoreEchoMethod::class, 30 | ]); 31 | } 32 | 33 | public function getName(): string 34 | { 35 | return "urn:ietf:params:jmap:core"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Core/Capabilities/CoreCapability/CoreType/CoreEchoMethod.php: -------------------------------------------------------------------------------- 1 | CoreCapability\CoreType\CoreEchoMethod::class, 43 | * ]); 44 | * } 45 | * 46 | * @return Map 47 | */ 48 | abstract public function getMethods(): Map; 49 | 50 | /** 51 | * Get the capability identifier 52 | * 53 | * @return string Capability identifier, e.g. urn:ietf:params:jmap:core 54 | */ 55 | abstract public function getName(): string; 56 | 57 | public function jsonSerialize() 58 | { 59 | return $this->getCapabilities(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Core/CapabilityFactory.php: -------------------------------------------------------------------------------- 1 | get("config"); 20 | 21 | $capabilityConfig = array_key_exists($requestedName, $config) 22 | ? $config[$requestedName] 23 | : []; 24 | 25 | return new $requestedName($capabilityConfig); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Core/Controllers/AbstractController.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | private $config; 23 | 24 | /** 25 | * Construct a new controller 26 | * 27 | * @param RequestContext $context JMAP request context 28 | * @param array $config JMAP core config 29 | */ 30 | public function __construct(RequestContext $context, array $config) 31 | { 32 | $this->context = $context; 33 | $this->config = $config; 34 | } 35 | 36 | /** 37 | * Get the JMAP request context 38 | * 39 | * @return RequestContext 40 | */ 41 | public function getContext(): RequestContext 42 | { 43 | return $this->context; 44 | } 45 | 46 | /** 47 | * Get a config option 48 | * 49 | * @param string $key Config option key 50 | * @return mixed 51 | */ 52 | public function getOption(string $key) 53 | { 54 | return $this->config[$key]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Core/Controllers/AbstractControllerFactory.php: -------------------------------------------------------------------------------- 1 | get(RequestContext::class); 22 | $config = $container->get("config")[CoreCapability::class]; 23 | 24 | return new $requestedName($context, $config); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Core/Controllers/ApiController.php: -------------------------------------------------------------------------------- 1 | getHeaderLine("Content-Type"), "application/json") !== 0) { 38 | throw new \RuntimeException("Wrong Content-Type"); 39 | } 40 | 41 | return json_decode($request->getBody(), false, 512, JSON_THROW_ON_ERROR); 42 | } 43 | 44 | /** 45 | * Resolves a method call into a method response 46 | * 47 | * @param Invocation $methodCall - Client-provided method call 48 | * @param Vector $methodResponses - Vector of already processed method calls 49 | * @param Map $methods - Map of available methods within this request 50 | * @return Invocation The method response 51 | */ 52 | public function resolveMethodCall(Invocation $methodCall, Vector $methodResponses, Map $methods): Invocation 53 | { 54 | try { 55 | // 1. Resolve references to previous method results (See RFC 8620 Section 3.7) 56 | $methodCall->resolveResultReferences($methodResponses); 57 | 58 | // 2. Resolve the method 59 | $method = $methods->get($methodCall->getName()); 60 | if (!class_exists($method)) { 61 | throw new BadMethodCallException("Could not resolve method " . $method); 62 | } 63 | 64 | // 3. Execute the method 65 | $methodCallable = new $method(); 66 | $method::validate($methodCall, $this->getContext()); 67 | $methodResponse = $methodCallable->handle($methodCall, $this->getContext()); 68 | } catch (OutOfBoundsException $exception) { 69 | $methodResponse = $methodCall->withName("error")->withArguments(["type" => "unknownMethod"]); 70 | } catch (MethodInvocationException $exception) { 71 | $args = ["type" => $exception->getType()]; 72 | if ($exception->getMessage()) { 73 | $args["description"] = $exception->getMessage(); 74 | } 75 | $methodResponse = $methodCall->withName("error")->withArguments($args); 76 | } 77 | return $methodResponse; 78 | } 79 | 80 | /** 81 | * Dispatch a JMAP request and turn it into a JMAP response 82 | * 83 | * @param Request $request 84 | * @return Response 85 | */ 86 | public function dispatch(Request $request): Response 87 | { 88 | // 1. Build map with all supported methods of the used capabilities 89 | $methods = $this->getContext()->getSession()->resolveMethods($request->getUsedCapabilities()); 90 | 91 | // 2. For each methodCall, execute the corresponding method, then add it to the response 92 | $methodResponses = new Vector(); 93 | foreach ($request->getMethodCalls() as $methodCall) { 94 | $methodResponse = $this->resolveMethodCall($methodCall, $methodResponses, $methods); 95 | $methodResponses->push($methodResponse); 96 | } 97 | 98 | return new Response($this->getContext()->getSession(), $methodResponses); 99 | } 100 | 101 | /** 102 | * Process a JMAP API request 103 | * 104 | * @param ServerRequestInterface $request Server request 105 | * @return ResponseInterface HTTP response 106 | */ 107 | public function handle(ServerRequestInterface $request): ResponseInterface 108 | { 109 | // 1. Turn HTTP request into a JSON object 110 | // TODO Use PSR-7 middlewares for content negotiation and body parsing 111 | try { 112 | /** @var mixed */ 113 | $data = self::parseJsonBody($request); 114 | } catch (\Exception $exception) { 115 | return new NotJsonError(); 116 | } 117 | 118 | // 2. Ensure that the request data adheres the schema 119 | try { 120 | $this->getContext()->getValidator()->validate($data, "http://jmap.io/Request.json#"); 121 | } catch (ValidationException $exception) { 122 | return new NotRequestError($exception); 123 | } 124 | 125 | // 3. Turn the HTTP request into a JMAP request 126 | $jmapRequest = new Request($data->using, $data->methodCalls, $data->createdIds ?? []); 127 | 128 | // 4. Make sure that the amount of requested method calls does not exceed the limit 129 | if ($jmapRequest->getMethodCalls()->count() > $this->getOption("maxCallsInRequest")) { 130 | return new LimitError( 131 | "The maximum number of " . $this->getOption("maxCallsInRequest") . " method calls got exceeded.", 132 | "maxCallsInRequest" 133 | ); 134 | } 135 | 136 | // 5. Dispatch the JMAP request 137 | try { 138 | return $this->dispatch($jmapRequest); 139 | } catch (UnknownCapabilityException $exception) { 140 | return new UnknownCapabilityError($exception); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Core/Controllers/SessionController.php: -------------------------------------------------------------------------------- 1 | getContext()->getSession(), 200, [ 21 | 'Cache-Control' => ['no-cache, no-store, must-revalidate'] 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Exceptions/MethodInvocationException.php: -------------------------------------------------------------------------------- 1 | type = $type; 27 | } 28 | 29 | /** 30 | * Get the error type 31 | * 32 | * @return string 33 | */ 34 | public function getType(): string 35 | { 36 | return $this->type; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Core/Exceptions/UnknownCapabilityException.php: -------------------------------------------------------------------------------- 1 | */ 21 | private $arguments = null; 22 | 23 | /** @var string */ 24 | private $methodCallId = ''; 25 | 26 | /** 27 | * Construct a new Invocation 28 | * 29 | * @param string $name Method name, e.g. "Mailbox/get" 30 | * @param array $arguments Method arguments/response 31 | * @param string $methodCallId Client-provided Method Call ID, e.g. "#0" 32 | */ 33 | public function __construct(string $name, array $arguments, string $methodCallId) 34 | { 35 | $this->name = $name; 36 | $this->arguments = new Map($arguments); 37 | $this->methodCallId = $methodCallId; 38 | } 39 | 40 | /** 41 | * Get the invocation's name, e.g. "Mailbox/get" 42 | * 43 | * @return string 44 | */ 45 | public function getName(): string 46 | { 47 | return $this->name; 48 | } 49 | 50 | /** 51 | * Get the invocation's arguments 52 | * 53 | * @return Map 54 | */ 55 | public function getArguments(): Map 56 | { 57 | return $this->arguments; 58 | } 59 | 60 | /** 61 | * Get the method call ID which is an arbitrary string from the client 62 | * 63 | * @return string 64 | */ 65 | public function getMethodCallId(): string 66 | { 67 | return $this->methodCallId; 68 | } 69 | 70 | /** 71 | * Return a new instance with the specified arguments 72 | * 73 | * @param array $arguments 74 | * @return static 75 | */ 76 | public function withArguments(array $arguments) 77 | { 78 | $new = clone $this; 79 | $new->arguments = new Map($arguments); 80 | return $new; 81 | } 82 | 83 | /** 84 | * Return a new instance with the specified name 85 | * 86 | * @param string $name 87 | * @return static 88 | */ 89 | public function withName(string $name) 90 | { 91 | $new = clone $this; 92 | $new->name = $name; 93 | return $new; 94 | } 95 | 96 | /** 97 | * Resolve result references based on a Vector of Invocations 98 | * 99 | * @see https://tools.ietf.org/html/rfc8620#section-3.7 100 | * @param Vector $responses Vector of already computed Invocation response instances 101 | * @throws MethodInvocationException When a key is contained both in normal and referenced form 102 | * @return void 103 | */ 104 | public function resolveResultReferences(Vector $responses) 105 | { 106 | $mArgs = $this->getArguments(); 107 | // TODO do we need to deep-traverse the arguments? 108 | foreach ($mArgs as $key => $value) { 109 | if (mb_substr($key, 0, 1) !== "#") { 110 | continue; 111 | } 112 | 113 | $key = mb_substr($key, 1); 114 | if ($mArgs->hasKey($key)) { 115 | throw new MethodInvocationException( 116 | "invalidArguments", 117 | "The key '" . $key . "' is contained both in normal and referenced form." 118 | ); 119 | } 120 | 121 | $ref = new ResultReference($value->resultOf, $value->name, $value->path); 122 | $this->arguments->remove("#" . $key); 123 | $this->arguments->put($key, $ref->resolve($responses)); 124 | } 125 | } 126 | 127 | public function jsonSerialize() 128 | { 129 | return [$this->getName(), $this->getArguments(), $this->getMethodCallId()]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Core/JsonPointer.php: -------------------------------------------------------------------------------- 1 | */ 30 | private $path; 31 | 32 | /** 33 | * Create a new instance from a string 34 | * 35 | * @param string $pointer JSON Pointer path 36 | * @throws InvalidArgumentException When the first path item does not match the whole document 37 | * @return JsonPointer New instance evaluating this pointer 38 | */ 39 | public static function fromString(string $pointer): JsonPointer 40 | { 41 | $new = new self(); 42 | 43 | $pathItems = new Vector(explode("/", $pointer)); 44 | $isUriFragment = $pathItems->first() === "#"; 45 | 46 | // Make sure the first path item matches the whole document 47 | if ($pathItems->first() !== "" && $pathItems->first() !== "#") { 48 | throw new InvalidArgumentException("Path does not start with whole document"); 49 | } 50 | 51 | // Reset first item so URI fragments starting with #/ work 52 | $pathItems->set(0, ""); 53 | 54 | // Unescape the path 55 | $new->path = $pathItems->map(function ($pathItem) use ($isUriFragment) { 56 | return str_replace(["~1", "~0"], ["/", "~"], $isUriFragment ? urldecode($pathItem) : $pathItem); 57 | }); 58 | 59 | return $new; 60 | } 61 | 62 | /** 63 | * Create a new instance from a path Vector 64 | * 65 | * @param Vector $path JSON Pointer Vector, as created by self::fromString 66 | * @return JsonPointer New instance evaluating this pointer 67 | */ 68 | public static function fromPath(Vector $path): JsonPointer 69 | { 70 | $new = new self(); 71 | $new->path = $path; 72 | return $new; 73 | } 74 | 75 | /** 76 | * Get the parsed Pointer path 77 | * 78 | * @return Vector Pointer path 79 | */ 80 | public function getPath(): Vector 81 | { 82 | return $this->path; 83 | } 84 | 85 | /** 86 | * Get an element from an array/object by it's key 87 | * 88 | * @param mixed $obj Object that should be queried 89 | * @param string $key Key/Index of the element 90 | * @throws OutOfRangeException When the key/index is not found 91 | * @return mixed Object element 92 | */ 93 | private static function get(&$obj, string $key) 94 | { 95 | if ($obj instanceof \stdClass) { 96 | if (!property_exists($obj, $key)) { 97 | throw new OutOfRangeException("Failed to fetch key: '" . $key . "'"); 98 | } 99 | return $obj->{$key}; 100 | } 101 | 102 | if (!isset($obj[$key])) { 103 | throw new OutOfRangeException("Failed to fetch key: '" . $key . "'"); 104 | } 105 | 106 | return $obj[$key]; 107 | } 108 | 109 | /** 110 | * Evaluate the JSON pointer onto an object 111 | * 112 | * @param mixed $data Decoded JSON object to apply the pointer onto 113 | * @return mixed Result at pointer location 114 | */ 115 | public function evaluate($data) 116 | { 117 | $cur = $data; 118 | foreach ($this->getPath()->slice(1) as $i => $pathItem) { 119 | if ($pathItem === "*" && is_array($cur)) { 120 | // JMAP extension to JSON Pointer 121 | $remainingPath = (new Vector([""]))->merge($this->getPath()->slice($i + 2)); 122 | $newPointer = self::fromPath($remainingPath); 123 | 124 | $acc = new Vector(); 125 | foreach ($cur as $item) { 126 | $ev = $newPointer->evaluate($item); 127 | if ($ev instanceof Vector) { 128 | $acc->push(...$ev); 129 | } else { 130 | $acc->push($ev); 131 | } 132 | } 133 | 134 | $cur = $acc; 135 | break; 136 | } 137 | 138 | $cur = self::get($cur, $pathItem); 139 | } 140 | 141 | return $cur; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Core/Method.php: -------------------------------------------------------------------------------- 1 | getValidator()->validate($request->getArguments(), "http://jmap.io/methods/changes.json#"); 14 | } 15 | 16 | abstract public function handle(Invocation $request, RequestContext $context): Invocation; 17 | } 18 | -------------------------------------------------------------------------------- /src/Core/Methods/CopyMethod.php: -------------------------------------------------------------------------------- 1 | getValidator()->validate($request->getArguments(), "http://jmap.io/methods/copy.json#"); 14 | } 15 | 16 | abstract public function handle(Invocation $request, RequestContext $context): Invocation; 17 | } 18 | -------------------------------------------------------------------------------- /src/Core/Methods/GetMethod.php: -------------------------------------------------------------------------------- 1 | getValidator()->validate($request->getArguments(), "http://jmap.io/methods/get.json#"); 14 | } 15 | 16 | abstract public function handle(Invocation $request, RequestContext $context): Invocation; 17 | } 18 | -------------------------------------------------------------------------------- /src/Core/Methods/QueryChangesMethod.php: -------------------------------------------------------------------------------- 1 | getValidator()->validate($request->getArguments(), "http://jmap.io/methods/queryChanges.json#"); 14 | } 15 | 16 | abstract public function handle(Invocation $request, RequestContext $context): Invocation; 17 | } 18 | -------------------------------------------------------------------------------- /src/Core/Methods/QueryMethod.php: -------------------------------------------------------------------------------- 1 | getValidator()->validate($request->getArguments(), "http://jmap.io/methods/query.json#"); 14 | } 15 | 16 | abstract public function handle(Invocation $request, RequestContext $context): Invocation; 17 | } 18 | -------------------------------------------------------------------------------- /src/Core/Methods/SetMethod.php: -------------------------------------------------------------------------------- 1 | getValidator()->validate($request->getArguments(), "http://jmap.io/methods/set.json#"); 14 | } 15 | 16 | abstract public function handle(Invocation $request, RequestContext $context): Invocation; 17 | } 18 | -------------------------------------------------------------------------------- /src/Core/README.md: -------------------------------------------------------------------------------- 1 | # JMAP Core 2 | 3 | This library implements the JMAP core according to [RFC 8620](https://tools.ietf.org/html/rfc8620). It is used as a foundation for implementing data models that can be synced with a server using JMAP via so-called _capabilities_. 4 | 5 | Check out the PHPDocs! 6 | 7 | ## Usage 8 | 9 | The main entry point is an instance of the class `Barnslig\Jmap\Core\JMAP`. It exposes multiple [PSR-15](https://www.php-fig.org/psr/psr-15/) compliant HTTP Server Request Handlers: 10 | 11 | - `JMAP::sessionHandler` 12 | - Implements the [JMAP Session Resource](https://tools.ietf.org/html/rfc8620#section-2) 13 | - Mountpoint: `/.well-known/jmap` 14 | - `JMAP::apiHandler` 15 | - Implements the `apiUrl` endpoint of the Session Resource. See [3.1. Making an API Request](https://tools.ietf.org/html/rfc8620#section-3.1) 16 | - Mountpoint: Arbitrary as long as it matches the `apiUrl` value of the Session Resource. Proposed: `/api` 17 | 18 | For a full example using [League\Route\Router](https://github.com/thephpleague/route), see [public_html/index.php](/public_html/index.php). 19 | 20 | ## Session 21 | 22 | A session consists of capabilities, accounts and endpoints. It is used to hold the state of the API while processing a request. 23 | 24 | ## Structure of a JMAP API 25 | 26 | ### Capabilities 27 | 28 | Capabilities are used to extend the functionality of a JMAP API. Examples for capabilities are Mail, Contacts and Calendars. 29 | 30 | A single JMAP API usually has multiple capabilities, at least `urn:ietf:params:jmap:core`. 31 | 32 | At every request, the API determines the set of used capabilities via the `using` property, see [3.3. The Request Object](https://tools.ietf.org/html/rfc8620#section-3.3). 33 | 34 | ### Types 35 | 36 | Every capability consists of types that provide methods. For example, a capability of type `urn:ietf:params:jmap:mail` may have a type called `Mailbox`. 37 | 38 | Types define an interface for creating, retrieving, updating, and deleting objects of their kind. 39 | 40 | ### Methods 41 | 42 | Every type consists of at least one method. They are what is actually called during a request. Using the previous example, records of type `Mailbox` would be fetched via a `Mailbox/get` call, modified via a `Mailbox/set` call etc. 43 | 44 | ### Invocation 45 | 46 | Invocations represent method calls and responses. An invocation is a 3-tuple consisting of: 47 | 48 | 1. Method name 49 | 1. Arguments object 50 | 1. Method call ID 51 | 52 | Example: `["Mailbox/get", { "accountId": "A13824" }, "#0]` 53 | 54 | The method call ID is an arbitrary string from the client to be echoed back with the response. It is used by the client to re-identify responses when issuing multiple method calls during a single request and by the server to resolve references to the results of other method call during response computation. 55 | 56 | A server's response uses the same 3-tuple with the arguments replaced by the method's return value. 57 | 58 | ## Implementing a capability 59 | 60 | A capability MUST extend the abstract class [Capability](Capability.php). Within the capability class, it then registers its types and corresponding methods which are implemented using the [Type](Type.php) and [Method](Method.php) interfaces. 61 | 62 | For an example, check out the [CoreCapability](Capabilities/). 63 | 64 | Types usually use [Standard Methods](https://tools.ietf.org/html/rfc8620#section-5). To ease development, the library provides them as abstract Method classes with already built-in [JSON Schema](https://json-schema.org/) request validation: 65 | 66 | - [GetMethod](Methods/GetMethod.php) implementing [5.1. /get](https://tools.ietf.org/html/rfc8620#section-5.1) 67 | - [ChangesMethod](Methods/ChangesMethod.php) implementing [5.2. /changes](https://tools.ietf.org/html/rfc8620#section-5.2) 68 | - [SetMethod](Methods/SetMethod.php) implementing [5.3. /set](https://tools.ietf.org/html/rfc8620#section-5.3) 69 | - [CopyMethod](Methods/CopyMethod.php) implementing [5.4. /copy](https://tools.ietf.org/html/rfc8620#section-5.4) 70 | - [QueryMethod](Methods/Queryethod.php) implementing [5.5. /query](https://tools.ietf.org/html/rfc8620#section-5.5) 71 | - [QueryChangesMethod](Methods/QueryChangesethod.php) implementing [5.6. /queryChanges](https://tools.ietf.org/html/rfc8620#section-5.6) 72 | 73 | When a method invocation fails, it MUST throw a [MethodInvocationException](Exceptions/MethodInvocationException.php). 74 | -------------------------------------------------------------------------------- /src/Core/Request.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | private $using; 21 | 22 | /** 23 | * Method calls 24 | * 25 | * @var Vector 26 | */ 27 | private $methodCalls; 28 | 29 | /** 30 | * Object ID mappings 31 | * 32 | * @var Map 33 | */ 34 | private $createdIds; 35 | 36 | /** 37 | * Construct a new Request instance 38 | * 39 | * @param array $using Set of capabilities the client wishes to use 40 | * @param array $methodCalls Method calls to be processed 41 | * @param array $createdIds Optional. A map of a (client-specified) creation id to the id the 42 | * server assigned when a record was successfully created 43 | */ 44 | public function __construct(array $using, array $methodCalls, array $createdIds = []) 45 | { 46 | // 1.1. Set used capabilities 47 | $this->using = new Vector($using); 48 | 49 | // 1.2. Sort the capability identifiers to canonicalize them for e.g. caching 50 | $this->using->sort(); 51 | 52 | // 2. Turn method calls into Invocation instances 53 | $this->methodCalls = (new Vector($methodCalls))->map(function ($methodCall) { 54 | return new Invocation($methodCall[0], (array) $methodCall[1], $methodCall[2]); 55 | }); 56 | 57 | // 3. Set created IDs 58 | $this->createdIds = new Map($createdIds); 59 | } 60 | 61 | /** 62 | * Get used capabilities 63 | * 64 | * @return Vector Used capabilities 65 | */ 66 | public function getUsedCapabilities(): Vector 67 | { 68 | return $this->using; 69 | } 70 | 71 | /** 72 | * Get method calls 73 | * 74 | * @return Vector Method calls 75 | */ 76 | public function getMethodCalls(): Vector 77 | { 78 | return $this->methodCalls; 79 | } 80 | 81 | /** 82 | * Get created IDs 83 | * 84 | * @return Map Created IDs 85 | */ 86 | public function getCreatedIds(): Map 87 | { 88 | return $this->createdIds; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Core/RequestContext.php: -------------------------------------------------------------------------------- 1 | session = $session; 36 | $this->validator = $validator; 37 | } 38 | 39 | /** 40 | * Get the JMAP session 41 | * 42 | * @return Session 43 | */ 44 | public function getSession(): Session 45 | { 46 | return $this->session; 47 | } 48 | 49 | /** 50 | * Get the JSON Schema validator 51 | * 52 | * @return ValidatorInterface 53 | */ 54 | public function getValidator(): ValidatorInterface 55 | { 56 | return $this->validator; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Core/RequestContextFactory.php: -------------------------------------------------------------------------------- 1 | get(Session::class); 22 | $validator = $container->get(ValidatorInterface::class); 23 | 24 | return new RequestContext($session, $validator); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Core/RequestError.php: -------------------------------------------------------------------------------- 1 | */ 24 | private $error; 25 | 26 | /** 27 | * Create a new request error 28 | * 29 | * @param string $type Error type, e.g. "urn:ietf:params:jmap:error:notJSON" 30 | * @param int $status HTTP error status, e.g. 400 31 | * @param array $error Additional associative error data that is merged with type and status 32 | */ 33 | public function __construct(string $type, int $status, array $error) 34 | { 35 | $this->type = $type; 36 | $this->status = $status; 37 | $this->error = $error; 38 | 39 | parent::__construct($this, $this->status, [ 40 | 'Content-Type' => 'application/problem+json' 41 | ]); 42 | } 43 | 44 | public function jsonSerialize() 45 | { 46 | return array_merge([ 47 | "type" => $this->type, 48 | "status" => $this->status 49 | ], $this->error); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Core/RequestErrors/LimitError.php: -------------------------------------------------------------------------------- 1 | $detail, 22 | "limit" => $limit 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Core/RequestErrors/NotJsonError.php: -------------------------------------------------------------------------------- 1 | 21 | "The content type of the request was not application/json or the request did not parse as I-JSON." 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/RequestErrors/NotRequestError.php: -------------------------------------------------------------------------------- 1 | $exception->getMessage() 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/RequestErrors/UnknownCapabilityError.php: -------------------------------------------------------------------------------- 1 | $exception->getMessage() 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Core/Response.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | private $methodResponses; 25 | 26 | /** 27 | * Construct a new Response instance 28 | * 29 | * @param Session $session Current session, used to determine the `sessionState` 30 | * @param Vector $methodResponses Method responses 31 | */ 32 | public function __construct(Session $session, Vector $methodResponses) 33 | { 34 | $this->session = $session; 35 | $this->methodResponses = $methodResponses; 36 | 37 | parent::__construct($this); 38 | } 39 | 40 | public function jsonSerialize() 41 | { 42 | return [ 43 | "methodResponses" => $this->methodResponses, 44 | "createdIds" => (object)[], 45 | "sessionState" => $this->session->getState() 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Core/ResultReference.php: -------------------------------------------------------------------------------- 1 | resultOf = $resultOf; 36 | $this->name = $name; 37 | $this->path = $path; 38 | } 39 | 40 | /** 41 | * Resolve the reference from a Vector of response Invocations 42 | * 43 | * @param Vector $responses Vector of already computed response Invocations 44 | * @throws MethodInvocationException When there is not matching response for the methodCallId/name combination 45 | * @throws MethodInvocationException When the path could not be resolved 46 | * @return mixed Result at pointer location 47 | */ 48 | public function resolve(Vector $responses) 49 | { 50 | // 1. Find the first response where methodCallId and name match 51 | $source = null; 52 | foreach ($responses as $response) { 53 | if ($response->getMethodCallId() === $this->resultOf && $response->getName() === $this->name) { 54 | $source = $response; 55 | break; 56 | } 57 | } 58 | if (!$source) { 59 | throw new MethodInvocationException( 60 | "invalidResultReference", 61 | "methodCallId or name do not match any previous invocation" 62 | ); 63 | } 64 | 65 | // 2. Evaluate the JSON Pointer 66 | try { 67 | $pointer = JsonPointer::fromString($this->path); 68 | return $pointer->evaluate($source->getArguments()); 69 | } catch (\Exception $exception) { 70 | throw new MethodInvocationException("invalidResultReference", "Path could not be resolved"); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Core/Schemas/DirLoader.php: -------------------------------------------------------------------------------- 1 | loaded[$uri])) { 29 | return $this->loaded[$uri]; 30 | } 31 | 32 | // Check the mapping 33 | foreach ($this->map as $prefix => $dir) { 34 | if (strpos($uri, $prefix) === 0) { 35 | // We have a match 36 | $path = substr($uri, strlen($prefix) + 1); 37 | $path = $dir . '/' . ltrim($path, '/'); 38 | 39 | if (file_exists($path)) { 40 | // Load the schema file 41 | $rawSchema = file_get_contents($path); 42 | if ($rawSchema === false) { 43 | throw new RuntimeException("Cannot load schema " . $path); 44 | } 45 | 46 | // Create a schema object 47 | $schema = Schema::fromJsonString($rawSchema); 48 | 49 | // Save it for reuse 50 | $this->loaded[$uri] = $schema; 51 | 52 | return $schema; 53 | } 54 | } 55 | } 56 | 57 | // Nothing found 58 | return null; 59 | } 60 | 61 | /** 62 | * Map a URL prefix to a path prefix 63 | * 64 | * @param string $dir Path prefix, e.g. schemas/ 65 | * @param string $uriPrefix URI prefix, e.g. http://example.com 66 | * @return bool Whether the path prefix is not a directory 67 | */ 68 | public function registerPath(string $dir, string $uriPrefix): bool 69 | { 70 | if (!is_dir($dir)) { 71 | return false; 72 | } 73 | 74 | $uriPrefix = rtrim($uriPrefix, '/'); 75 | $dir = rtrim($dir, '/'); 76 | 77 | $this->map[$uriPrefix] = $dir; 78 | 79 | return true; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Core/Schemas/ValidationException.php: -------------------------------------------------------------------------------- 1 | loader = $loader; 31 | $this->validator = $validator; 32 | $this->presenter = $presenter; 33 | } 34 | 35 | public function validate($data, string $uri): void 36 | { 37 | /* Convert Ds\Map structures so we can validate them. 38 | * This is necessary as, for example, Invocation is using Ds\Map to 39 | * store arguments which we commonly validate. 40 | */ 41 | if ($data instanceof Map) { 42 | $data = (object)$data->toArray(); 43 | } 44 | 45 | $result = $this->validator->uriValidation($data, $uri); 46 | 47 | if ($result->hasErrors()) { 48 | $presented = $this->presenter->present(...$result->getErrors())[0]; 49 | $msg = "Error validating #/" . implode("/", $presented->pointer()) . ": " . $presented->message(); 50 | throw new ValidationException($msg); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Core/Schemas/ValidatorFactory.php: -------------------------------------------------------------------------------- 1 | registerPath(__DIR__ . "/schemas/", "http://jmap.io"); 26 | 27 | $validator = new OpisValidator(null, $loader); 28 | 29 | $presenter = new ValidationErrorPresenter( 30 | new PresentedValidationErrorFactory(new MessageFormatterFactory()), 31 | new BestMatchError() 32 | ); 33 | 34 | return new Validator($loader, $validator, $presenter); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Core/Schemas/ValidatorInterface.php: -------------------------------------------------------------------------------- 1 | */ 18 | private $capabilities; 19 | 20 | /** @var Map */ 21 | private $accounts; 22 | 23 | public function __construct() 24 | { 25 | $this->capabilities = new Map(); 26 | $this->accounts = new Map(); 27 | } 28 | 29 | /** 30 | * Add a capability to the JMAP server 31 | * 32 | * @param Capability $capability Instance of the corresponding capability class 33 | * @return void 34 | */ 35 | public function addCapability(Capability $capability): void 36 | { 37 | $this->capabilities->put($capability->getName(), $capability); 38 | } 39 | 40 | /** 41 | * Get the session's hash that the client uses to determine change 42 | * 43 | * @return string 44 | */ 45 | public function getState(): string 46 | { 47 | // TODO cache ? 48 | return mb_substr(sha1(serialize($this->jsonSerialize(false))), 0, 12); 49 | } 50 | 51 | /** 52 | * Resolves a list of capabilities to a map of methods 53 | * 54 | * @param Vector $usedCapabilities Vector of capabilities 55 | * @return Map The keys are full method names (e.g. "Email/get") 56 | * @throws UnknownCapabilityException When an unknown capability is used 57 | */ 58 | public function resolveMethods(Vector $usedCapabilities): Map 59 | { 60 | // TODO cache or some other kind of efficiency increase ? 61 | $methods = new Map(); 62 | foreach ($usedCapabilities as $capabilityKey) { 63 | if (!$this->capabilities->hasKey($capabilityKey)) { 64 | throw new UnknownCapabilityException( 65 | "The Request object used capability '" . $capabilityKey . "', which is not supported by this server" 66 | ); 67 | } 68 | 69 | $capability = $this->capabilities->get($capabilityKey); 70 | $methods->putAll($capability->getMethods()); 71 | } 72 | return $methods; 73 | } 74 | 75 | /** 76 | * Data used to serialize the Session into JSON 77 | * 78 | * @param bool $withState Whether the session's state hash should be included 79 | * @return array 80 | */ 81 | public function jsonSerialize(bool $withState = true) 82 | { 83 | $state = $withState ? $this->getState() : null; 84 | return [ 85 | "capabilities" => $this->capabilities, 86 | "accounts" => $this->accounts, 87 | "primaryAccounts" => [], 88 | "username" => null, 89 | "apiUrl" => null, 90 | "downloadUrl" => null, 91 | "uploadUrl" => null, 92 | "eventSourceUrl" => null, 93 | "state" => $state 94 | ]; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Core/SessionFactory.php: -------------------------------------------------------------------------------- 1 | get('config')['session']; 20 | 21 | $session = new Session(); 22 | foreach ($config['capabilities'] as $capability) { 23 | $session->addCapability($container->get($capability)); 24 | } 25 | 26 | return $session; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Mail/MailCapability.php: -------------------------------------------------------------------------------- 1 | */ 11 | private $options; 12 | 13 | /** 14 | * @param array $options 15 | */ 16 | public function __construct($options = []) 17 | { 18 | $this->options = $options; 19 | } 20 | 21 | public function getCapabilities(): object 22 | { 23 | return (object)$this->options; 24 | } 25 | 26 | public function getMethods(): Map 27 | { 28 | return new Map([ 29 | "Mailbox/get" => MailCapability\MailboxType\MailboxGetMethod::class 30 | ]); 31 | } 32 | 33 | public function getName(): string 34 | { 35 | return "urn:ietf:params:jmap:mail"; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Mail/MailCapability/MailboxType/MailboxGetMethod.php: -------------------------------------------------------------------------------- 1 | "bar" 18 | ]; 19 | } 20 | 21 | public function getMethods(): Map 22 | { 23 | return new Map(); 24 | } 25 | 26 | public function getName(): string 27 | { 28 | return "urn:ietf:params:jmap:test"; 29 | } 30 | }; 31 | 32 | $this->assertEquals(json_encode($capability), json_encode(["foo" => "bar"])); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Core/Controllers/ApiControllerTest.php: -------------------------------------------------------------------------------- 1 | session = new Session(); 38 | $this->validator = new PassingValidatorStub(); 39 | $this->context = new RequestContext($this->session, $this->validator); 40 | 41 | $this->controller = new ApiController($this->context, [ 42 | 'maxCallsInRequest' => 16, 43 | ]); 44 | } 45 | 46 | /** 47 | * Assert that two objects are equal via their JSON representation 48 | * 49 | * @param mixed $actual 50 | * @param mixed $expected 51 | * @throws \PHPUnit\Framework\ExpectationFailedException 52 | */ 53 | protected function assertEqualsViaJson($actual, $expected) 54 | { 55 | $this->assertEquals( 56 | json_decode(json_encode($actual) ?: "null", false, 512, JSON_THROW_ON_ERROR), 57 | json_decode(json_encode($expected) ?: "null", false, 512, JSON_THROW_ON_ERROR) 58 | ); 59 | } 60 | 61 | public function testParseJsonBodyChecksContentType() 62 | { 63 | $req = $this->prophesize(ServerRequestInterface::class); 64 | $req->getHeaderLine("Content-Type")->willReturn("text/html"); 65 | 66 | $this->expectException(\RuntimeException::class); 67 | 68 | $this->controller->parseJsonBody($req->reveal()); 69 | } 70 | 71 | public function testParseJsonBody() 72 | { 73 | $expected = [ 74 | "foo" => "bar" 75 | ]; 76 | 77 | $req = $this->prophesize(ServerRequestInterface::class); 78 | $req->getHeaderLine("Content-Type")->willReturn("application/json"); 79 | $req->getBody()->willReturn(json_encode($expected)); 80 | 81 | $json = $this->controller->parseJsonBody($req->reveal()); 82 | 83 | $this->assertEqualsViaJson($json, $expected); 84 | } 85 | 86 | public function testResolveMethodCallNotImplementedRaises() 87 | { 88 | $methodCall = new Invocation("Foo/bar", [], "#0"); 89 | $methodResponses = new Vector(); 90 | $methods = new Map([ 91 | "Foo/bar" => "notexisting" 92 | ]); 93 | 94 | $this->expectException(\BadMethodCallException::class); 95 | 96 | // @phpstan-ignore-next-line 97 | $response = $this->controller->resolveMethodCall($methodCall, $methodResponses, $methods); 98 | } 99 | 100 | public function testResolveMethodCallUnknownMethodError() 101 | { 102 | $methodCall = new Invocation("Foo/bar", [], "#0"); 103 | $methodResponses = new Vector(); 104 | $methods = new Map(); 105 | 106 | $response = $this->controller->resolveMethodCall($methodCall, $methodResponses, $methods); 107 | 108 | $this->assertEqualsViaJson($response, ["error", ["type" => "unknownMethod"], "#0"]); 109 | } 110 | 111 | public function testResolveMethodCallInvocationError() 112 | { 113 | $methodCall = new Invocation("Foo/raising", [], "#0"); 114 | $methodResponses = new Vector(); 115 | $methods = new Map([ 116 | "Foo/raising" => RaisingMethodStub::class 117 | ]); 118 | 119 | $response = $this->controller->resolveMethodCall($methodCall, $methodResponses, $methods); 120 | 121 | $this->assertEqualsViaJson($response, ["error", ["type" => "test", "description" => "testmessage"], "#0"]); 122 | } 123 | 124 | public function testResolveMethodCall() 125 | { 126 | $methodCall = new Invocation("Core/echo", [ 127 | "#bla" => (object)[ 128 | "resultOf" => "#0", 129 | "name" => "Core/echo", 130 | "path" => "/bar" 131 | ] 132 | ], "#1"); 133 | $methodResponses = new Vector([ 134 | new Invocation("Core/echo", ["bar" => "baz"], "#0") 135 | ]); 136 | $methods = new Map([ 137 | "Core/echo" => CoreEchoMethod::class 138 | ]); 139 | 140 | $response = $this->controller->resolveMethodCall($methodCall, $methodResponses, $methods); 141 | 142 | $this->assertEqualsViaJson($response, ["Core/echo", ["bla" => "baz"], "#1"]); 143 | } 144 | 145 | public function testDispatch() 146 | { 147 | // 1. add core capability with Core/echo method 148 | $this->session->addCapability(new CoreCapability()); 149 | 150 | // 2. create request that calls Core/echo method 151 | $using = ["urn:ietf:params:jmap:core"]; 152 | $methodCalls = [ 153 | [ 154 | "Core/echo", 155 | (object)["bar" => "baz"], 156 | "#0" 157 | ], 158 | [ 159 | "Core/echo", 160 | (object)[ 161 | "#bla" => (object)[ 162 | "resultOf" => "#0", 163 | "name" => "Core/echo", 164 | "path" => "/bar" 165 | ] 166 | ], 167 | "#1" 168 | ] 169 | ]; 170 | $request = new Request($using, $methodCalls); 171 | 172 | // 3. dispatch request 173 | $response = $this->controller->dispatch($request); 174 | 175 | // 4. compare result 176 | $expectedResponse = [ 177 | "methodResponses" => [ 178 | ["Core/echo", ["bar" => "baz"], "#0"], 179 | ["Core/echo", ["bla" => "baz"], "#1"] 180 | ], 181 | "createdIds" => (object)[], 182 | "sessionState" => $this->session->getState() 183 | ]; 184 | 185 | $this->assertEqualsViaJson($response, $expectedResponse); 186 | } 187 | 188 | public function testHandleNotJson() 189 | { 190 | $req = $this->prophesize(ServerRequestInterface::class); 191 | $req->getHeaderLine("Content-Type")->willReturn("application/json"); 192 | $req->getBody()->willReturn(","); 193 | 194 | $res = $this->controller->handle($req->reveal()); 195 | 196 | $this->assertEquals($res->getStatusCode(), 400); 197 | $this->assertEquals($res->getHeader("Content-Type"), ["application/problem+json"]); 198 | 199 | $this->assertEquals(json_decode($res->getBody()), (object)[ 200 | "type" => "urn:ietf:params:jmap:error:notRequest", 201 | "status" => 400, 202 | "detail" => 203 | "The content type of the request was not application/json or the request did not parse as I-JSON." 204 | ]); 205 | } 206 | 207 | public function testHandleNotRequest() 208 | { 209 | $this->validator = new FailingValidatorStub(); 210 | $this->context = new RequestContext($this->session, $this->validator); 211 | $this->controller = new ApiController($this->context, []); 212 | 213 | $req = $this->prophesize(ServerRequestInterface::class); 214 | $req->getHeaderLine("Content-Type")->willReturn("application/json"); 215 | $req->getBody()->willReturn("{}"); 216 | 217 | $res = $this->controller->handle($req->reveal()); 218 | 219 | $this->assertEquals($res->getStatusCode(), 400); 220 | $this->assertEquals($res->getHeader("Content-Type"), ["application/problem+json"]); 221 | 222 | $this->assertEquals(json_decode($res->getBody()), (object)[ 223 | "type" => "urn:ietf:params:jmap:error:notRequest", 224 | "status" => 400, 225 | "detail" => "" 226 | ]); 227 | } 228 | 229 | public function testHandleTooManyCalls() 230 | { 231 | $this->controller = new ApiController($this->context, [ 232 | 'maxCallsInRequest' => 0 233 | ]); 234 | 235 | $req = $this->prophesize(ServerRequestInterface::class); 236 | $req->getHeaderLine("Content-Type")->willReturn("application/json"); 237 | $req->getBody()->willReturn('{ 238 | "using": [ 239 | "urn:ietf:params:jmap:core" 240 | ], 241 | "methodCalls": [ 242 | ["Core/echo", { "foo": "bar" }, "#1"], 243 | ["Core/echo", { "foo": "bar" }, "#2"] 244 | ] 245 | }'); 246 | 247 | $res = $this->controller->handle($req->reveal()); 248 | 249 | $this->assertEquals($res->getStatusCode(), 400); 250 | $this->assertEquals($res->getHeader("Content-Type"), ["application/problem+json"]); 251 | 252 | $this->assertEquals(json_decode($res->getBody()), (object)[ 253 | "type" => "urn:ietf:params:jmap:error:limit", 254 | "status" => 400, 255 | "detail" => "The maximum number of 0 method calls got exceeded.", 256 | "limit" => "maxCallsInRequest" 257 | ]); 258 | } 259 | 260 | public function testHandleUnknownCapability() 261 | { 262 | $req = $this->prophesize(ServerRequestInterface::class); 263 | $req->getHeaderLine("Content-Type")->willReturn("application/json"); 264 | $req->getBody()->willReturn('{ 265 | "using": [ 266 | "urn:ietf:params:jmap:test-unknown" 267 | ], 268 | "methodCalls": [] 269 | }'); 270 | 271 | $res = $this->controller->handle($req->reveal()); 272 | 273 | $this->assertEquals($res->getStatusCode(), 400); 274 | $this->assertEquals($res->getHeader("Content-Type"), ["application/problem+json"]); 275 | 276 | $this->assertEquals(json_decode($res->getBody()), (object)[ 277 | "type" => "urn:ietf:params:jmap:error:unknownCapability", 278 | "status" => 400, 279 | "detail" => 280 | "The Request object used capability 'urn:ietf:params:jmap:test-unknown', " . 281 | "which is not supported by this server" 282 | ]); 283 | } 284 | 285 | public function testHandle() 286 | { 287 | $this->session->addCapability(new CoreCapability()); 288 | 289 | $req = $this->prophesize(ServerRequestInterface::class); 290 | $req->getHeaderLine("Content-Type")->willReturn("application/json"); 291 | $req->getBody()->willReturn('{ 292 | "using": [ 293 | "urn:ietf:params:jmap:core" 294 | ], 295 | "methodCalls": [ 296 | [ 297 | "Core/echo", 298 | { 299 | "foo": { 300 | "bar": { 301 | "baz": "lol" 302 | } 303 | } 304 | }, 305 | "#1" 306 | ], 307 | [ 308 | "Core/echo", 309 | { 310 | "#test": { 311 | "resultOf": "#1", 312 | "name": "Core/echo", 313 | "path": "/foo/bar/baz" 314 | } 315 | }, 316 | "#2" 317 | ] 318 | ] 319 | }'); 320 | 321 | $res = $this->controller->handle($req->reveal()); 322 | 323 | $this->assertEquals($res->getStatusCode(), 200); 324 | $this->assertEquals($res->getHeader("Content-Type"), ["application/json"]); 325 | 326 | $decodedRes = json_decode($res->getBody()); 327 | $this->assertEquals($decodedRes->methodResponses, [ 328 | [ 329 | "Core/echo", (object)[ 330 | "foo" => (object)[ 331 | "bar" => (object)[ 332 | "baz" => "lol" 333 | ] 334 | ] 335 | ], 336 | "#1" 337 | ], 338 | [ 339 | "Core/echo", (object)[ 340 | "test" => "lol" 341 | ], 342 | "#2" 343 | ] 344 | ]); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /tests/Core/Controllers/SessionControllerTest.php: -------------------------------------------------------------------------------- 1 | session = new Session(); 30 | $this->validator = new PassingValidatorStub(); 31 | $this->context = new RequestContext($this->session, $this->validator); 32 | 33 | $this->controller = new SessionController($this->context, []); 34 | } 35 | 36 | public function testHandle() 37 | { 38 | $req = $this->prophesize(ServerRequestInterface::class); 39 | 40 | $res = $this->controller->handle($req->reveal()); 41 | 42 | $this->assertEquals($res->getStatusCode(), 200); 43 | $this->assertEquals($res->getHeader("Content-Type"), ["application/json"]); 44 | $this->assertEquals($res->getHeader("Cache-Control"), ["no-cache, no-store, must-revalidate"]); 45 | 46 | $this->assertEquals($res->getBody(), json_encode($this->session)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Core/InvocationTest.php: -------------------------------------------------------------------------------- 1 | "bar", 16 | "#foo" => (object)[ 17 | "resultOf" => "#0", 18 | "name" => "Foo/bar", 19 | "path" => "/bar/baz" 20 | ] 21 | ]; 22 | $i = new Invocation("Foo/bar", $args, "#1"); 23 | 24 | $this->expectException(\RuntimeException::class); 25 | $i->resolveResultReferences(new Vector()); 26 | } 27 | 28 | public function testResolveResultReference() 29 | { 30 | $args = [ 31 | "#foo" => (object)[ 32 | "resultOf" => "#0", 33 | "name" => "Foo/bar", 34 | "path" => "/bar/baz" 35 | ] 36 | ]; 37 | 38 | $responses = new Vector([ 39 | new Invocation("Foo/bar", [ 40 | "bar" => (object)[ 41 | "baz" => "bla" 42 | ] 43 | ], "#0") 44 | ]); 45 | 46 | $i = new Invocation("Foo/bar", $args, "#1"); 47 | $i->resolveResultReferences($responses); 48 | 49 | $this->assertFalse($i->getArguments()->hasKey("#foo")); 50 | $this->assertEquals($i->getArguments()->get("foo"), "bla"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Core/JsonPointerTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 28 | JsonPointer::fromString("foo/bar"); 29 | } 30 | 31 | public function testParsePath() 32 | { 33 | $p = JsonPointer::fromString("/foo/bar"); 34 | $this->assertEquals($p->getPath()->toArray(), ["", "foo", "bar"]); 35 | } 36 | 37 | public function testParseEscapedPath() 38 | { 39 | $p = JsonPointer::fromString("/fo~1o/ba~01r/ba~0z"); 40 | $this->assertEquals($p->getPath()->toArray(), ["", "fo/o", "ba~1r", "ba~z"]); 41 | } 42 | 43 | public function testParseUriPath() 44 | { 45 | $p = JsonPointer::fromString("#/fo%7E1o/ba%7E01r/ba%7E0z"); 46 | $this->assertEquals($p->getPath()->toArray(), ["", "fo/o", "ba~1r", "ba~z"]); 47 | } 48 | 49 | public function testEvaluate() 50 | { 51 | $data = json_decode(self::$json); 52 | 53 | // Tests from RFC Section 5 54 | $tests = [ 55 | ["", $data], 56 | ["/foo", ["bar", "baz"]], 57 | ["/foo/0", "bar"], 58 | ["/", 0], 59 | ["/a~1b", 1], 60 | ["/c%d", 2], 61 | ["/e^f", 3], 62 | ["/g|h", 4], 63 | ["/i\\j", 5], 64 | ["/k\"l", 6], 65 | ["/ ", 7], 66 | ["/m~0n", 8], 67 | ]; 68 | 69 | foreach ($tests as $test) { 70 | $p = JsonPointer::fromString($test[0]); 71 | $this->assertEquals($p->evaluate($data), $test[1]); 72 | } 73 | } 74 | 75 | public function testEvaluateUriFragment() 76 | { 77 | $data = json_decode(self::$json); 78 | 79 | // Tests from RFC Section 6 80 | $tests = [ 81 | ["#", $data], 82 | ["#/foo", ["bar", "baz"]], 83 | ["#/foo/0", "bar"], 84 | ["#/", 0], 85 | ["#/a~1b", 1], 86 | ["#/c%25d", 2], 87 | ["#/e%5Ef", 3], 88 | ["#/g%7Ch", 4], 89 | ["#/i%5Cj", 5], 90 | ["#/k%22l", 6], 91 | ["#/%20", 7], 92 | ["#/m~0n", 8] 93 | ]; 94 | 95 | foreach ($tests as $test) { 96 | $p = JsonPointer::fromString($test[0]); 97 | $this->assertEquals($p->evaluate($data), $test[1]); 98 | } 99 | } 100 | 101 | public function testEvaluateArrayNonNumericRaises() 102 | { 103 | $data = json_decode(self::$json); 104 | 105 | // An array referenced with a non-numeric token raises an exception 106 | // $this->expectException(\TypeError::class); 107 | $this->expectException(\OutOfRangeException::class); 108 | 109 | $p = JsonPointer::fromString("/foo/a"); 110 | $p->evaluate($data); 111 | } 112 | 113 | public function testEvaluateUnknownKeyRaises() 114 | { 115 | $data = (object)[ 116 | "foo" => "bar" 117 | ]; 118 | 119 | // An stdClass referenced with a non-existing key raises an exception 120 | $this->expectException(\OutOfRangeException::class); 121 | 122 | $p = JsonPointer::fromString("/bar"); 123 | $p->evaluate($data); 124 | } 125 | 126 | public function testEvaluateJmap() 127 | { 128 | $data = (object)[ 129 | "foo" => [ 130 | (object)[ 131 | "bar" => [ 132 | (object)[ 133 | "baz" => "test1" 134 | ], 135 | (object)[ 136 | "baz" => "test2" 137 | ] 138 | ] 139 | ], 140 | (object)[ 141 | "bar" => [ 142 | (object)[ 143 | "baz" => "test3" 144 | ] 145 | ] 146 | ] 147 | ] 148 | ]; 149 | 150 | $expected = [ 151 | "test1", 152 | "test2", 153 | "test3" 154 | ]; 155 | 156 | $p = JsonPointer::fromString("/foo/*/bar/*/baz"); 157 | $this->assertEquals($p->evaluate($data)->toArray(), $expected); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/Core/RequestTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($req->getUsedCapabilities()->toArray(), ["a", "c"]); 20 | } 21 | 22 | public function testRequestCreatesInvocations() 23 | { 24 | $using = ["urn:ietf:params:jmap:core"]; 25 | $methodCalls = [ 26 | ["Foo/bar", (object)[], "#0"], 27 | ["Foo/bar", (object)[], "#1"] 28 | ]; 29 | 30 | $req = new Request($using, $methodCalls); 31 | 32 | $this->assertContainsOnlyInstancesOf(Invocation::class, $req->getMethodCalls()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Core/ResponseTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 20 | json_encode($res), 21 | json_encode([ 22 | "methodResponses" => $methodResponses, 23 | "createdIds" => (object)[], 24 | "sessionState" => $session->getState() 25 | ]) 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Core/ResultReferenceTest.php: -------------------------------------------------------------------------------- 1 | rr = new ResultReference("#0", "Foo/bar", "/bar/baz"); 19 | } 20 | 21 | public function testRaiseUnknownInvocation() 22 | { 23 | $responses = new Vector(); 24 | 25 | $this->expectException(MethodInvocationException::class); 26 | $this->rr->resolve($responses); 27 | } 28 | 29 | public function testRaisesUnknownPath() 30 | { 31 | $responses = new Vector([ 32 | new Invocation("Foo/bar", [ 33 | "bar" => (object)[ 34 | "bla" => "bla" 35 | ] 36 | ], "#0") 37 | ]); 38 | 39 | $this->expectException(MethodInvocationException::class); 40 | $this->rr->resolve($responses); 41 | } 42 | 43 | public function testResolves() 44 | { 45 | $responses = new Vector([ 46 | new Invocation("Foo/bar", [ 47 | "bar" => (object)[ 48 | "baz" => "bla" 49 | ] 50 | ], "#0") 51 | ]); 52 | 53 | $this->assertEquals($this->rr->resolve($responses), "bla"); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Core/SessionTest.php: -------------------------------------------------------------------------------- 1 | session = new Session(); 21 | } 22 | 23 | public function testGetState() 24 | { 25 | $state = $this->session->getState(); 26 | $this->assertTrue(mb_strlen($state) == 12); 27 | } 28 | 29 | public function testResolveMethodsUnknownCapabilityThrows() 30 | { 31 | $this->expectException(UnknownCapabilityException::class); 32 | 33 | $this->session->resolveMethods(new Vector(["urn:ietf:params:jmap:test"])); 34 | } 35 | 36 | public function testResolveMethods() 37 | { 38 | $capability = new class extends Capability { 39 | public function getCapabilities(): object 40 | { 41 | return (object)[]; 42 | } 43 | 44 | public function getMethods(): Map 45 | { 46 | return new Map([ 47 | "Foo/bar" => CoreEchoMethod::class 48 | ]); 49 | } 50 | 51 | public function getName(): string 52 | { 53 | return "urn:ietf:params:jmap:test"; 54 | } 55 | }; 56 | $this->session->addCapability($capability); 57 | 58 | $methods = $this->session->resolveMethods(new Vector(["urn:ietf:params:jmap:test"])); 59 | $this->assertEquals($methods->toArray(), ["Foo/bar" => CoreEchoMethod::class]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Core/Stubs/FailingValidatorStub.php: -------------------------------------------------------------------------------- 1 |