├── test ├── fixtures │ └── finder │ │ ├── x │ │ ├── a.php │ │ ├── b.apib │ │ └── c.apib │ │ ├── y │ │ ├── x.apib │ │ └── y.apib │ │ └── z │ │ └── i.apib ├── system │ ├── schemas │ │ └── schema1.json │ ├── schemaExternal.apib │ ├── test1.apib │ ├── schema.apib │ └── index.php ├── integration │ ├── ApibToTestSuites │ │ ├── Metadata.test │ │ ├── ApiNameAndOverview.test │ │ ├── ResourceEmpty.test │ │ ├── ActionEmpty.test │ │ ├── ActionRequestNoUri.test │ │ ├── ActionResponseEmpty.test │ │ ├── ActionResponse.test │ │ ├── SchemaExternal.test │ │ ├── ActionRequestResponse.test │ │ └── Schema.test │ ├── Target.php │ └── Parser.php └── unit │ ├── Extension.php │ ├── Finder.php │ ├── Http │ ├── Response.php │ └── Requester.php │ └── Compiler.php ├── .gitignore ├── res ├── logo.png ├── example.apib ├── template │ └── html.php └── overview.svg ├── .bootstrap.atoum.php ├── src ├── Exception │ ├── Exception.php │ └── ParserEOF.php ├── IntermediateRepresentation │ ├── Message.php │ ├── Group.php │ ├── Action.php │ ├── Request.php │ ├── Response.php │ ├── Resource.php │ ├── Payload.php │ └── Document.php ├── Finder.php ├── Http │ ├── Response.php │ └── Requester.php ├── JsonSchema │ └── UriRetriever.php ├── Compiler.php ├── Configuration.php ├── extension.php ├── Asserter │ └── Json.php ├── test.php ├── Target.php └── Parser.php ├── .travis.yml ├── .atoum.php ├── composer.json ├── LICENSE ├── README.md └── bin └── atoum-apiblueprint-render /test/fixtures/finder/x/a.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/finder/x/b.apib: -------------------------------------------------------------------------------- 1 | foo -------------------------------------------------------------------------------- /test/fixtures/finder/x/c.apib: -------------------------------------------------------------------------------- 1 | bar -------------------------------------------------------------------------------- /test/fixtures/finder/y/x.apib: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/finder/y/y.apib: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/fixtures/finder/z/i.apib: -------------------------------------------------------------------------------- 1 | baz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock -------------------------------------------------------------------------------- /res/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HappyMicky0317/atoum-apiblueprint-extension/HEAD/res/logo.png -------------------------------------------------------------------------------- /.bootstrap.atoum.php: -------------------------------------------------------------------------------- 1 | addToRunner($runner); 5 | 6 | $extension->getConfiguration()->mountJsonSchemaDirectory('test', __DIR__ . '/test/system/schemas/'); 7 | $extension->getAPIBFinder()->append(new FilesystemIterator(__DIR__ . '/test/system/')); 8 | 9 | $extension->compileAndEnqueue(); 10 | -------------------------------------------------------------------------------- /test/integration/ApibToTestSuites/Metadata.test: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://example.org/ 3 | 4 | ---[to]--- 5 | 6 | namespace atoum\apiblueprint\generated; 7 | 8 | class Unknown0e17030576bafa1b4700220c87de294f5d979c96 extends \atoum\apiblueprint\test 9 | { 10 | protected $_host = null; 11 | 12 | public function beforeTestMethod($testMethod) 13 | { 14 | $this->_host = 'https://example.org/'; 15 | } 16 | } -------------------------------------------------------------------------------- /test/system/schema.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: http://127.0.0.1:8888/ 3 | 4 | # Schema 5 | 6 | # R 1 [/schema] 7 | 8 | ## Foo Bar [GET] 9 | 10 | + Response 200 (application/json) 11 | 12 | + Schema 13 | 14 | { 15 | "$schema": "http://json-schema.org/draft-04/schema#", 16 | "type": "object", 17 | "properties": { 18 | "message": { 19 | "type": "string" 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Finder.php: -------------------------------------------------------------------------------- 1 | getBasename(), -5, 5); 15 | } 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/IntermediateRepresentation/Group.php: -------------------------------------------------------------------------------- 1 | _host = 'https://example.org/'; 20 | } 21 | } -------------------------------------------------------------------------------- /src/IntermediateRepresentation/Action.php: -------------------------------------------------------------------------------- 1 | _host = 'https://example.org/'; 21 | } 22 | 23 | public function test resource r 1() 24 | { 25 | $this->skip('No action for the resource `R 1`.'); 26 | } 27 | 28 | public function test resource r 2() 29 | { 30 | $this->skip('No action for the resource `R 2`.'); 31 | } 32 | } -------------------------------------------------------------------------------- /test/system/index.php: -------------------------------------------------------------------------------- 1 | get( 8 | 'test1', 9 | '/test1', 10 | function () { 11 | header('Content-Type: text/plain;charset=UTF-8'); 12 | 13 | echo 'Hello, World!'; 14 | } 15 | ) 16 | ->get( 17 | 'schema', 18 | '/schema', 19 | function () { 20 | header('Content-Type: application/json'); 21 | 22 | echo '{"message": "Hello, World!"}'; 23 | } 24 | ); 25 | 26 | try { 27 | $dispatcher = new Hoa\Dispatcher\Basic(); 28 | $dispatcher->dispatch($router); 29 | } catch (\Exception $e) { 30 | header('HTTP/1.1 404 Not Found'); 31 | } 32 | -------------------------------------------------------------------------------- /src/IntermediateRepresentation/Payload.php: -------------------------------------------------------------------------------- 1 | _host = 'https://example.org/'; 27 | } 28 | 29 | public function test resource r 1 action foo bar() 30 | { 31 | $this->skip('Action `Foo Bar` for the resource `R 1` has no message.'); 32 | } 33 | 34 | public function test resource r 1 action baz qux() 35 | { 36 | $this->skip('Action `Baz Qux` for the resource `R 1` has no message.'); 37 | } 38 | 39 | public function test resource r 2 action unknown0() 40 | { 41 | $this->skip('Action `` for the resource `R 2` has no message.'); 42 | } 43 | } -------------------------------------------------------------------------------- /src/IntermediateRepresentation/Document.php: -------------------------------------------------------------------------------- 1 | _host = 'https://example.org/'; 23 | } 24 | 25 | public function test resource r 1 action foo bar transaction 0() 26 | { 27 | $requester = new \atoum\apiblueprint\Http\Requester(); 28 | $expectedResponses = []; 29 | 30 | $requester->addRequest( 31 | 'GET', 32 | $this->_host . '/group/a/resource/1', 33 | [ 34 | ] 35 | ); 36 | $expectedResponses[] = [ 37 | 'statusCode' => 200, 38 | 'mediaType' => '', 39 | 'headers' => [ 40 | ], 41 | 'body' => '', 42 | 'schema' => '', 43 | ]; 44 | 45 | $this->responsesMatch($requester->send(), $expectedResponses); 46 | } 47 | } -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | statusCode = $statusCode; 17 | $this->url = $url; 18 | $this->headers = $headers; 19 | $this->body = $body; 20 | } 21 | 22 | public static function fromCurlResponse(int $statusCode, string $url, string $headers, string $body) 23 | { 24 | return new static($statusCode, $url, static::parseRawHeaders($headers), $body); 25 | } 26 | 27 | public static function parseRawHeaders(string $headers): array 28 | { 29 | $out = []; 30 | 31 | foreach (preg_split('/\v+/', $headers) as $line) { 32 | if (false === $pos = strpos($line, ':')) { 33 | continue; 34 | } 35 | 36 | $out[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1)); 37 | } 38 | 39 | return $out; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/integration/ApibToTestSuites/ActionResponseEmpty.test: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://example.org/ 3 | 4 | # API Name 5 | 6 | # R 1 [/group/a/resource/1] 7 | 8 | ## Foo Bar [DELETE /group/a/resource/1/action/foo-bar] 9 | 10 | + Response 204 (media/type2) 11 | 12 | ---[to]--- 13 | 14 | namespace atoum\apiblueprint\generated; 15 | 16 | class API Name extends \atoum\apiblueprint\test 17 | { 18 | protected $_host = null; 19 | 20 | public function beforeTestMethod($testMethod) 21 | { 22 | $this->_host = 'https://example.org/'; 23 | } 24 | 25 | public function test resource r 1 action foo bar transaction 0() 26 | { 27 | $requester = new \atoum\apiblueprint\Http\Requester(); 28 | $expectedResponses = []; 29 | 30 | $requester->addRequest( 31 | 'DELETE', 32 | $this->_host . '/group/a/resource/1/action/foo-bar', 33 | [ 34 | ] 35 | ); 36 | $expectedResponses[] = [ 37 | 'statusCode' => 204, 38 | 'mediaType' => 'media/type2', 39 | 'headers' => [ 40 | ], 41 | 'body' => '', 42 | 'schema' => '', 43 | ]; 44 | 45 | $this->responsesMatch($requester->send(), $expectedResponses); 46 | } 47 | } -------------------------------------------------------------------------------- /test/integration/ApibToTestSuites/ActionResponse.test: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://example.org/ 3 | 4 | # API Name 5 | 6 | # R 1 [/group/a/resource/1] 7 | 8 | ## Foo Bar [GET /group/a/resource/1/action/foo-bar] 9 | 10 | + Response 200 (media/type2) 11 | 12 | + Body 13 | 14 | Foo Bar 15 | 16 | ---[to]--- 17 | 18 | namespace atoum\apiblueprint\generated; 19 | 20 | class API Name extends \atoum\apiblueprint\test 21 | { 22 | protected $_host = null; 23 | 24 | public function beforeTestMethod($testMethod) 25 | { 26 | $this->_host = 'https://example.org/'; 27 | } 28 | 29 | public function test resource r 1 action foo bar transaction 0() 30 | { 31 | $requester = new \atoum\apiblueprint\Http\Requester(); 32 | $expectedResponses = []; 33 | 34 | $requester->addRequest( 35 | 'GET', 36 | $this->_host . '/group/a/resource/1/action/foo-bar', 37 | [ 38 | ] 39 | ); 40 | $expectedResponses[] = [ 41 | 'statusCode' => 200, 42 | 'mediaType' => 'media/type2', 43 | 'headers' => [ 44 | ], 45 | 'body' => 'Foo Bar', 46 | 'schema' => '', 47 | ]; 48 | 49 | $this->responsesMatch($requester->send(), $expectedResponses); 50 | } 51 | } -------------------------------------------------------------------------------- /test/integration/ApibToTestSuites/SchemaExternal.test: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://example.org/ 3 | 4 | # API Name 5 | 6 | # R 1 [/group/a/resource/1] 7 | 8 | ## Foo Bar [GET /group/a/resource/1/action/foo-bar] 9 | 10 | + Response 200 (media/type2) 11 | 12 | + Schema 13 | 14 | { 15 | "$ref": "foobar.json" 16 | } 17 | 18 | ---[to]--- 19 | 20 | namespace atoum\apiblueprint\generated; 21 | 22 | class API Name extends \atoum\apiblueprint\test 23 | { 24 | protected $_host = null; 25 | 26 | public function beforeTestMethod($testMethod) 27 | { 28 | $this->_host = 'https://example.org/'; 29 | } 30 | 31 | public function test resource r 1 action foo bar transaction 0() 32 | { 33 | $requester = new \atoum\apiblueprint\Http\Requester(); 34 | $expectedResponses = []; 35 | 36 | $requester->addRequest( 37 | 'GET', 38 | $this->_host . '/group/a/resource/1/action/foo-bar', 39 | [ 40 | ] 41 | ); 42 | $expectedResponses[] = [ 43 | 'statusCode' => 200, 44 | 'mediaType' => 'media/type2', 45 | 'headers' => [ 46 | ], 47 | 'body' => '', 48 | 'schema' => '{ 49 | "$ref": "foobar.json" 50 | }', 51 | ]; 52 | 53 | $this->responsesMatch($requester->send(), $expectedResponses); 54 | } 55 | } -------------------------------------------------------------------------------- /test/unit/Extension.php: -------------------------------------------------------------------------------- 1 | given( 17 | $this->mockGenerator->orphanize('__construct'), 18 | $runner = new \mock\mageekguy\atoum\runner(), 19 | $this->calling($runner)->addExtension->doesNothing(), 20 | 21 | $extension = new SUT() 22 | ) 23 | ->when($result = $extension->addToRunner($runner)) 24 | ->then 25 | ->object($result) 26 | ->isIdenticalTo($extension) 27 | ->mock($runner) 28 | ->call('addExtension')->withIdenticalArguments($extension)->once(); 29 | } 30 | 31 | public function test_get_apib_finder() 32 | { 33 | $this 34 | ->given($extension = new SUT()) 35 | ->when($result = $extension->getAPIBFinder()) 36 | ->then 37 | ->object($result) 38 | ->isInstanceOf(\AppendIterator::class) 39 | ->object($extension->getRawAPIBFinder()->getInnerIterator()) 40 | ->isIdenticalTo($result); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "atoum/apiblueprint-extension", 3 | "type" : "library", 4 | "description": "Compile and run tests written in the API Blueprint format (`.apib`) with atoum", 5 | "keywords" : ["atoum", "test", "atoum-extension", "apiblueprint"], 6 | "homepage" : "http://www.atoum.org", 7 | "license" : "MIT", 8 | "authors" : [ 9 | { 10 | "name" : "Ivan Enderlin", 11 | "email": "ivan.enderlin@hoa-project.net" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "atoum\\apiblueprint\\" : "src", 17 | "atoum\\apiblueprint\\test\\": "test" 18 | } 19 | }, 20 | "require": { 21 | "php" : ">7.0", 22 | "ext-curl" : "*", 23 | "ext-mbstring" : "*", 24 | "atoum/atoum" : "~3.2", 25 | "hoa/ustring" : "~4.0", 26 | "justinrainbow/json-schema": "~5.2", 27 | "league/commonmark" : "~0.16" 28 | }, 29 | "require-dev": { 30 | "hoa/dispatcher": "~1.0", 31 | "hoa/router" : "~3.0" 32 | }, 33 | "minimum-stability": "beta", 34 | "scripts": { 35 | "test": "php -S 127.0.0.1:8888 -t test/system/ > /dev/null 2>&1 & test_php_pid=$!; vendor/bin/atoum --test-ext --debug --force-terminal; kill $test_php_pid" 36 | }, 37 | "bin": ["bin/atoum-apiblueprint-render"] 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Ivan Enderlin. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of Frédéric Hardy nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY FRÉDÉRIC HARDY AND CONTRIBUTORS ``AS IS'' AND ANY 17 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL FRÉDÉRIC HARDY AND CONTRIBUTORS BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /src/JsonSchema/UriRetriever.php: -------------------------------------------------------------------------------- 1 | _router[$root]) && 33 | true === file_exists($this->_router[$root] . $path)) { 34 | return parent::retrieve($this->_router[$root] . $path); 35 | } 36 | 37 | return parent::retrieve($uri); 38 | } 39 | 40 | /** 41 | * Mount a directory. 42 | */ 43 | public function mount(string $rootName, string $rootPath) 44 | { 45 | $this->_router[$rootName] = $rootPath . DIRECTORY_SEPARATOR; 46 | } 47 | 48 | public function unmount(string $rootName) 49 | { 50 | unset($this->_router[$rootName]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/integration/Target.php: -------------------------------------------------------------------------------- 1 | getProperty('resource'); 20 | $resource->setAccessible(true); 21 | 22 | $uri = stream_get_meta_data(tmpfile())['uri']; 23 | $collector = new file($uri); 24 | 25 | $this 26 | ->executeOnFailure( 27 | function () use (&$file, $uri) { 28 | echo 29 | 'Temporary file is `', $uri, '`.', "\n", 30 | 'Using file `', $file->getBasename(), '`.', "\n"; 31 | } 32 | ); 33 | 34 | foreach ($files as $file) { 35 | list($input, $output) = preg_split('/\s+---\[to\]---\s+/', file_get_contents($file->getPathname())); 36 | 37 | $target->compile($parser->parse($input), $collector); 38 | 39 | $this 40 | ->string(file_get_contents($uri)) 41 | ->isEqualTo($output); 42 | 43 | $collectorFileDescriptor = $resource->getValue($collector); 44 | ftruncate($collectorFileDescriptor, 0); 45 | rewind($collectorFileDescriptor); 46 | } 47 | 48 | unlink($uri); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/unit/Finder.php: -------------------------------------------------------------------------------- 1 | when($result = new SUT()) 16 | ->then 17 | ->object($result) 18 | ->isInstanceOf(\CallbackFilterIterator::class); 19 | } 20 | 21 | public function test_empty() 22 | { 23 | $this 24 | ->given($finder = new SUT()) 25 | ->when($result = iterator_to_array($finder)) 26 | ->then 27 | ->array($result) 28 | ->isEmpty(); 29 | } 30 | 31 | public function test_find_only_apib_files() 32 | { 33 | $this 34 | ->given( 35 | $fixtures = dirname(__DIR__) . '/fixtures/finder', 36 | $finder = new SUT(), 37 | $finder->append(new \FilesystemIterator($fixtures . '/x')), 38 | $finder->append(new \FilesystemIterator($fixtures . '/y')) 39 | ) 40 | ->when($result = iterator_to_array($finder)) 41 | ->then 42 | ->array( 43 | array_map( 44 | function (\SplFileInfo $item): string { 45 | return $item->getPathname(); 46 | }, 47 | $result 48 | ) 49 | ) 50 | ->hasSize(4) 51 | ->containsValues([ 52 | $fixtures . '/x/b.apib', 53 | $fixtures . '/x/c.apib', 54 | $fixtures . '/y/x.apib', 55 | $fixtures . '/y/y.apib' 56 | ]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/integration/ApibToTestSuites/ActionRequestResponse.test: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://example.org/ 3 | 4 | # API Name 5 | 6 | # R 1 [/group/a/resource/1] 7 | 8 | ## Foo Bar [GET /group/a/resource/1/action/foo-bar] 9 | 10 | + Request A (media/type1) 11 | 12 | + Headers 13 | 14 | Foo: Bar 15 | Baz: Qux 16 | 17 | + Schema 18 | 19 | { 20 | "$schema": "http://json-schema.org/draft-04/schema#", 21 | "type": "object", 22 | "properties": { 23 | "message": { 24 | "type": "string" 25 | } 26 | } 27 | } 28 | 29 | + Response 200 (media/type2) 30 | 31 | + Headers 32 | 33 | ooF: raB 34 | zaB: xuQ 35 | 36 | + Body 37 | 38 | Hello 39 | 40 | ---[to]--- 41 | 42 | namespace atoum\apiblueprint\generated; 43 | 44 | class API Name extends \atoum\apiblueprint\test 45 | { 46 | protected $_host = null; 47 | 48 | public function beforeTestMethod($testMethod) 49 | { 50 | $this->_host = 'https://example.org/'; 51 | } 52 | 53 | public function test resource r 1 action foo bar transaction 0() 54 | { 55 | $requester = new \atoum\apiblueprint\Http\Requester(); 56 | $expectedResponses = []; 57 | 58 | $requester->addRequest( 59 | 'GET', 60 | $this->_host . '/group/a/resource/1/action/foo-bar', 61 | [ 62 | 'foo' => 'Bar', 63 | 'baz' => 'Qux', 64 | ] 65 | ); 66 | $expectedResponses[] = [ 67 | 'statusCode' => 200, 68 | 'mediaType' => 'media/type2', 69 | 'headers' => [ 70 | 'oof' => 'raB', 71 | 'zab' => 'xuQ', 72 | ], 73 | 'body' => 'Hello', 74 | 'schema' => '', 75 | ]; 76 | 77 | $this->responsesMatch($requester->send(), $expectedResponses); 78 | } 79 | } -------------------------------------------------------------------------------- /src/Compiler.php: -------------------------------------------------------------------------------- 1 | write('parse( 39 | file_get_contents($splFileInfo->getPathname()) 40 | ); 41 | $target->compile($intermediateRepresentation, $outputFile); 42 | } 43 | 44 | return $outputFile->getFilename(); 45 | } 46 | 47 | public static function getParser(): Parser 48 | { 49 | if (null === static::$_parser) { 50 | static::$_parser = new Parser(); 51 | } 52 | 53 | return static::$_parser; 54 | } 55 | 56 | public static function getTarget(): Target 57 | { 58 | if (null === static::$_target) { 59 | static::$_target = new Target(); 60 | } 61 | 62 | return static::$_target; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/integration/ApibToTestSuites/Schema.test: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://example.org/ 3 | 4 | # API Name 5 | 6 | # R 1 [/group/a/resource/1] 7 | 8 | ## Foo Bar [GET /group/a/resource/1/action/foo-bar] 9 | 10 | + Request A (media/type1) 11 | 12 | + Schema 13 | 14 | { 15 | "$schema": "http://json-schema.org/draft-04/schema#", 16 | "type": "object", 17 | "properties": { 18 | "message": { 19 | "type": "string" 20 | } 21 | } 22 | } 23 | 24 | + Response 200 (media/type2) 25 | 26 | + Schema 27 | 28 | { 29 | "$schema": "http://json-schema.org/draft-04/schema#", 30 | "type": "object", 31 | "properties": { 32 | "another_message": { 33 | "type": "string" 34 | } 35 | } 36 | } 37 | 38 | ---[to]--- 39 | 40 | namespace atoum\apiblueprint\generated; 41 | 42 | class API Name extends \atoum\apiblueprint\test 43 | { 44 | protected $_host = null; 45 | 46 | public function beforeTestMethod($testMethod) 47 | { 48 | $this->_host = 'https://example.org/'; 49 | } 50 | 51 | public function test resource r 1 action foo bar transaction 0() 52 | { 53 | $requester = new \atoum\apiblueprint\Http\Requester(); 54 | $expectedResponses = []; 55 | 56 | $requester->addRequest( 57 | 'GET', 58 | $this->_host . '/group/a/resource/1/action/foo-bar', 59 | [ 60 | ] 61 | ); 62 | $expectedResponses[] = [ 63 | 'statusCode' => 200, 64 | 'mediaType' => 'media/type2', 65 | 'headers' => [ 66 | ], 67 | 'body' => '', 68 | 'schema' => '{ 69 | "$schema": "http://json-schema.org/draft-04/schema#", 70 | "type": "object", 71 | "properties": { 72 | "another_message": { 73 | "type": "string" 74 | } 75 | } 76 | }', 77 | ]; 78 | 79 | $this->responsesMatch($requester->send(), $expectedResponses); 80 | } 81 | } -------------------------------------------------------------------------------- /src/Http/Requester.php: -------------------------------------------------------------------------------- 1 | _curlMultiHandler) { 15 | $this->_curlMultiHandler = curl_multi_init(); 16 | } 17 | 18 | $curlHandler = curl_init(); 19 | $this->_curlHandlers[] = $curlHandler; 20 | 21 | $formattedHeaders = []; 22 | 23 | foreach ($headers as $headerName => $headerValue) { 24 | $formattedHeaders[] = $headerName . ': ' . $headerValue; 25 | } 26 | 27 | curl_setopt_array( 28 | $curlHandler, 29 | [ 30 | CURLOPT_RETURNTRANSFER => true, 31 | CURLOPT_HEADER => true, 32 | CURLOPT_CUSTOMREQUEST => $method, 33 | CURLOPT_URL => $url, 34 | CURLOPT_HTTPHEADER => $formattedHeaders 35 | ] 36 | ); 37 | 38 | curl_multi_add_handle($this->_curlMultiHandler, $curlHandler); 39 | } 40 | 41 | public function send(): \Generator 42 | { 43 | $running = null; 44 | 45 | do { 46 | curl_multi_exec($this->_curlMultiHandler, $running); 47 | curl_multi_select($this->_curlMultiHandler); 48 | } while (0 < $running); 49 | 50 | foreach ($this->_curlHandlers as $curlHandler) { 51 | $headerSize = curl_getinfo($curlHandler, CURLINFO_HEADER_SIZE); 52 | $response = curl_multi_getcontent($curlHandler); 53 | 54 | yield Response::fromCurlResponse( 55 | curl_getinfo($curlHandler, CURLINFO_HTTP_CODE), 56 | curl_getinfo($curlHandler, CURLINFO_EFFECTIVE_URL), 57 | substr($response, 0, $headerSize), 58 | substr($response, $headerSize) 59 | ); 60 | 61 | curl_multi_remove_handle($this->_curlMultiHandler, $curlHandler); 62 | curl_close($curlHandler); 63 | } 64 | 65 | curl_multi_close($this->_curlMultiHandler); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | _jsonSchemaMountPoints[$rootName] = $_directory; 35 | } 36 | 37 | /** 38 | * Remove a JSON schema directory that might have been mounted. 39 | */ 40 | public function unmountJsonSchemaDirectory(string $rootName) 41 | { 42 | unset($this->_router[$rootName]); 43 | } 44 | 45 | /** 46 | * Returns all the JSON schema mount points. 47 | */ 48 | public function getJsonSchemaMountPoints(): array 49 | { 50 | return $this->_jsonSchemaMountPoints; 51 | } 52 | 53 | /** 54 | * “Serialize” the configuration as an array. 55 | */ 56 | public function serialize() 57 | { 58 | return [ 59 | 'jsonSchemaMountPoints' => $this->getJsonSchemaMountPoints() 60 | ]; 61 | } 62 | 63 | /** 64 | * Allocate a `Configuration` object based on an array of data. 65 | */ 66 | public static function unserialize(array $configuration) 67 | { 68 | $self = new static(); 69 | 70 | if (isset($configuration['jsonSchemaMountPoints'])) { 71 | $self->_jsonSchemaMountPoints = $configuration['jsonSchemaMountPoints']; 72 | } 73 | 74 | return $self; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/unit/Http/Response.php: -------------------------------------------------------------------------------- 1 | given( 16 | $statusCode = 123, 17 | $url = '/foo/bar', 18 | $headers = ['baz' => 'qux'], 19 | $body = 'Hello, World!' 20 | ) 21 | ->when($result = new SUT($statusCode, $url, $headers, $body)) 22 | ->then 23 | ->integer($result->statusCode) 24 | ->isIdenticalTo($statusCode) 25 | ->string($result->url) 26 | ->isIdenticalTo($url) 27 | ->array($result->headers) 28 | ->isIdenticalTo($headers) 29 | ->string($result->body) 30 | ->isIdenticalTo($body); 31 | } 32 | 33 | public function test_from_curl_response() 34 | { 35 | $this 36 | ->given( 37 | $statusCode = 123, 38 | $url = '/foo/bar', 39 | $headers = 'Baz: Qux', 40 | $body = 'Hello, World!' 41 | ) 42 | ->when($result = SUT::fromCurlResponse($statusCode, $url, $headers, $body)) 43 | ->then 44 | ->integer($result->statusCode) 45 | ->isIdenticalTo($statusCode) 46 | ->string($result->url) 47 | ->isIdenticalTo($url) 48 | ->array($result->headers) 49 | ->isIdenticalTo([ 50 | 'baz' => 'Qux' 51 | ]) 52 | ->string($result->body) 53 | ->isIdenticalTo($body); 54 | } 55 | 56 | public function test_parse_empty_raw_headers() 57 | { 58 | $this 59 | ->given($headers = '') 60 | ->when($result = SUT::parseRawHeaders($headers)) 61 | ->then 62 | ->array($result) 63 | ->isEmpty(); 64 | } 65 | 66 | public function test_parse_raw_headers() 67 | { 68 | $this 69 | ->given($headers = 'Foo:Bar' . "\r\n" . 'Baz: Qux' . "\r\n") 70 | ->when($result = SUT::parseRawHeaders($headers)) 71 | ->then 72 | ->array($result) 73 | ->isEqualTo([ 74 | 'foo' => 'Bar', 75 | 'baz' => 'Qux' 76 | ]); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /test/unit/Compiler.php: -------------------------------------------------------------------------------- 1 | given( 19 | $compiler = new SUT(), 20 | $this->function->file_exists = function ($path) use ($self): bool { 21 | $self 22 | ->string($path) 23 | ->matches('#^' . sys_get_temp_dir() . '/atoum/apiblueprint/([^\.]+)/testSuite\.php$#'); 24 | 25 | return false; 26 | } 27 | ) 28 | ->when($result = $compiler->compile(new LUT\Finder())) 29 | ->then 30 | ->function('file_exists')->once(); 31 | } 32 | 33 | public function test_compile_to_an_empty_target() 34 | { 35 | $self = $this; 36 | 37 | $this 38 | ->given( 39 | $finder = new LUT\Finder(), 40 | $finder->getInnerIterator()->append(new \FilesystemIterator(dirname(__DIR__) . '/fixtures/finder/z')), 41 | 42 | $this->mockGenerator->orphanize('__construct'), 43 | $outputFile = new \mock\mageekguy\atoum\writers\file('php://memory'), 44 | $this->calling($outputFile)->write->doesNothing(), 45 | $this->calling($outputFile)->getFilename = 'php://memory', 46 | 47 | $parser = new \mock\atoum\apiblueprint\Parser(), 48 | $document = new LUT\IntermediateRepresentation\Document(), 49 | $this->calling($parser)->parse = $document, 50 | 51 | $target = new \mock\atoum\apiblueprint\Target(), 52 | $this->calling($target)->compile->doesNothing(), 53 | 54 | $compiler = new SUT() 55 | ) 56 | ->when($result = $compiler->compile($finder, $outputFile, $parser, $target)) 57 | ->then 58 | ->string($result) 59 | ->isEqualTo('php://memory') 60 | ->mock($parser) 61 | ->call('parse')->withIdenticalArguments('baz')->once() 62 | ->mock($target) 63 | ->call('compile')->withIdenticalArguments($document, $outputFile)->once(); 64 | } 65 | 66 | public function test_get_parser() 67 | { 68 | $this 69 | ->when($result = SUT::getParser()) 70 | ->then 71 | ->object($result) 72 | ->isInstanceOf(LUT\Parser::class) 73 | ->isIdenticalTo(SUT::getParser()); 74 | } 75 | 76 | public function test_get_target() 77 | { 78 | $this 79 | ->when($result = SUT::getTarget()) 80 | ->then 81 | ->object($result) 82 | ->isInstanceOf(LUT\Target::class) 83 | ->isIdenticalTo(SUT::getTarget()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/extension.php: -------------------------------------------------------------------------------- 1 | getScript()->getArgumentsParser(); 19 | 20 | $selfHandler = function ($script, $argument, $values) { 21 | $runner = $script->getRunner(); 22 | 23 | $runner->addTestsFromDirectory(dirname(__DIR__) . '/test/integration/'); 24 | $runner->addTestsFromDirectory(dirname(__DIR__) . '/test/unit/'); 25 | }; 26 | 27 | $directoryToTest = &$this->_directoryToTest; 28 | 29 | $extensionHandler = function ($script, $argument, $values) use (&$directoryToTest) { 30 | if (null !== $directoryToTest) { 31 | $script->getRunner()->addTestsFromDirectory($directoryToTest); 32 | } 33 | }; 34 | 35 | $parser 36 | ->addHandler($selfHandler, ['--test-ext']) 37 | ->addHandler($selfHandler, ['--test-it']) 38 | ->addHandler($extensionHandler, ['--extension-apiblueprint']); 39 | } 40 | 41 | $this->_configuration = new Configuration(); 42 | $this->_apibFinder = new Finder(); 43 | } 44 | 45 | public function addToRunner(atoum\runner $runner) 46 | { 47 | $runner->addExtension($this, $this->getConfiguration()); 48 | 49 | // This trick is necessary to add the directory containing the 50 | // generated tests after `atoum\runner::resetTestPaths` has been 51 | // called. 52 | $_SERVER['argv'][] = '--extension-apiblueprint'; 53 | 54 | return $this; 55 | } 56 | 57 | public function setRunner(atoum\runner $runner) 58 | { 59 | return $this; 60 | } 61 | 62 | public function setTest(atoum\test $test) 63 | { 64 | if ($test instanceof test) { 65 | $test->setJsonHandler($test->getExtensionConfiguration($this)); 66 | } 67 | 68 | return $this; 69 | } 70 | 71 | public function handleEvent($event, atoum\observable $observable) 72 | { 73 | } 74 | 75 | public function getConfiguration(): Configuration 76 | { 77 | return $this->_configuration; 78 | } 79 | 80 | /** 81 | * Return the real finder instance. 82 | */ 83 | public function getRawAPIBFinder(): Finder 84 | { 85 | return $this->_apibFinder; 86 | } 87 | 88 | /** 89 | * Return the inner iterator of the iterator, which is a 90 | * `AppendIterator`. The user can simply add file system iterators to the 91 | * finder. 92 | */ 93 | public function getAPIBFinder(): \AppendIterator 94 | { 95 | return $this->_apibFinder->getInnerIterator(); 96 | } 97 | 98 | public function compileAndEnqueue() 99 | { 100 | $this->_directoryToTest = dirname((new Compiler())->compile($this->_apibFinder)); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/unit/Http/Requester.php: -------------------------------------------------------------------------------- 1 | given( 19 | $method = 'GET', 20 | $url = '/foo/bar', 21 | $headers = ['Baz' => 'Qux'], 22 | 23 | $requester = new SUT(), 24 | 25 | $this->function->curl_multi_init->doesNothing(), 26 | $this->function->curl_init->doesNothing(), 27 | $this->function->curl_setopt_array = function ($curlHandler, array $options) use ($self, $method, $url) { 28 | $this 29 | ->array($options) 30 | ->isEqualTo([ 31 | CURLOPT_RETURNTRANSFER => true, 32 | CURLOPT_HEADER => true, 33 | CURLOPT_CUSTOMREQUEST => $method, 34 | CURLOPT_URL => $url, 35 | CURLOPT_HTTPHEADER => ['Baz: Qux'] 36 | ]); 37 | }, 38 | $this->function->curl_multi_add_handle->doesNothing() 39 | ) 40 | ->when($result = $requester->addRequest($method, $url, $headers)) 41 | ->then 42 | ->variable($result) 43 | ->isNull() 44 | ->function('curl_init') 45 | ->once() 46 | ->function('curl_setopt_array') 47 | ->once() 48 | ->function('curl_init') 49 | ->once(); 50 | } 51 | 52 | public function test_send() 53 | { 54 | $self = $this; 55 | 56 | $this 57 | ->given( 58 | $method = 'GET', 59 | $url = '/foo/bar', 60 | $headers = ['foo' => 'bar'], 61 | 62 | $this->function->curl_multi_init->doesNothing(), 63 | $this->function->curl_init->doesNothing(), 64 | $this->function->curl_setopt_array->doesNothing(), 65 | $this->function->curl_multi_add_handle->doesNothing(), 66 | 67 | $this->function->curl_multi_exec = function ($curlMultiHandler, &$running) { 68 | $running = -1; 69 | }, 70 | $this->function->curl_multi_select->doesNothing(), 71 | 72 | $this->function->curl_getinfo = function ($curlHandler, $info) use ($url) { 73 | switch ($info) { 74 | case CURLINFO_HEADER_SIZE: 75 | return 7; 76 | 77 | case CURLINFO_HTTP_CODE: 78 | return 123; 79 | 80 | case CURLINFO_EFFECTIVE_URL: 81 | return $url; 82 | } 83 | }, 84 | $this->function->curl_multi_getcontent = 'foo:barHello, World!', 85 | 86 | $this->function->curl_multi_remove_handle->doesNothing(), 87 | $this->function->curl_close->doesNothing(), 88 | $this->function->curl_multi_close->doesNothing(), 89 | 90 | $requester = new SUT(), 91 | $requester->addRequest($method, $url, $headers), 92 | $requester->addRequest($method, $url, $headers) 93 | ) 94 | ->when($result = $requester->send()) 95 | ->then 96 | ->generator($result) 97 | ->yields 98 | ->object 99 | ->isInstanceOf(Response::class) 100 | ->isEqualTo(new Response(123, $url, $headers, 'Hello, World!')) 101 | ->yields 102 | ->object 103 | ->isInstanceOf(Response::class) 104 | ->isEqualTo(new Response(123, $url, $headers, 'Hello, World!')) 105 | ->function('curl_multi_exec') 106 | ->once() 107 | ->function('curl_multi_select') 108 | ->once(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Asserter/Json.php: -------------------------------------------------------------------------------- 1 | valueIsSet()->_innerAsserter->$name; 23 | } 24 | 25 | public function __call($method, $arguments) 26 | { 27 | return $this->valueIsSet()->_innerAsserter->$method(...$arguments); 28 | } 29 | 30 | public function setWith($value, $charlist = null, $checkType = true) 31 | { 32 | parent::setWith($value, $charlist, $checkType); 33 | 34 | if (false === self::isJson($value)) { 35 | $this->fail(sprintf($this->getLocale()->_('%s is not a valid JSON string'), $this)); 36 | } 37 | 38 | $this->_data = json_decode($value); 39 | 40 | if (true === is_array($this->_data)) { 41 | $this->_innerAsserter = new atoum\asserters\phpArray($this->getGenerator()); 42 | } elseif (true === is_object($this->_data)) { 43 | $this->_innerAsserter = new atoum\asserters\phpObject($this->getGenerator()); 44 | } else { 45 | $this->_innerAsserter = new asserters\variable($this->getGenerator()); 46 | } 47 | 48 | $this->_innerAsserter->setWith($this->_data); 49 | 50 | return $this; 51 | } 52 | 53 | public function setJsonSchemaUriRetriever(JsonSchema\Uri\Retrievers\UriRetrieverInterface $uriRetriever): self 54 | { 55 | $retriever = new JsonSchema\Uri\UriRetriever(); 56 | $retriever->setUriRetriever($uriRetriever); 57 | 58 | $this->_jsonSchemaStorage = new JsonSchema\SchemaStorage($retriever); 59 | 60 | return $this; 61 | } 62 | 63 | public function fulfills(string $schema): self 64 | { 65 | $schemaObject = $this->toSchemaObject($schema); 66 | $validator = new JsonSchema\Validator(); 67 | 68 | $validator->validate( 69 | $this->valueIsSet()->_data, 70 | $schemaObject, 71 | JsonSchema\Constraints\Constraint::CHECK_MODE_VALIDATE_SCHEMA 72 | ); 73 | 74 | if ($validator->isValid() === true) { 75 | $this->pass(); 76 | } else { 77 | $violations = $validator->getErrors(); 78 | $count = count($violations); 79 | $message = sprintf( 80 | $this->getLocale()->__( 81 | 'The JSON response body does not validate the given schema. Found %d violation:', 82 | 'The JSON response body does not validate the given schema. Found %d violations:', 83 | $count 84 | ), 85 | $count 86 | ); 87 | 88 | foreach ($validator->getErrors() as $index => $error) { 89 | $message .= 90 | "\n" . 91 | sprintf( 92 | ' %d. `%s`: %s', 93 | $index + 1, 94 | $error['property'], 95 | $error['message'] 96 | ); 97 | } 98 | 99 | $this->fail($message); 100 | } 101 | 102 | return $this; 103 | } 104 | 105 | protected function valueIsSet($message = 'JSON is undefined') 106 | { 107 | return parent::valueIsSet($message); 108 | } 109 | 110 | protected static function isJson(string $value): bool 111 | { 112 | $decoded = @json_decode($value); 113 | 114 | return 115 | null === error_get_last() && 116 | (null !== $decoded || 'null' === strtolower(trim($value))); 117 | } 118 | 119 | protected function toSchemaObject(string $schema): \StdClass 120 | { 121 | $schemaStorage = $this->_jsonSchemaStorage; 122 | $schemaObject = @json_decode($schema); 123 | 124 | if ($schemaObject === null) { 125 | throw new atoum\exceptions\logic\invalidArgument('Invalid JSON schema'); 126 | } 127 | 128 | if (true === property_exists($schemaObject, '$ref')) { 129 | $schemaObject = $schemaStorage->resolveRef($schemaObject->{'$ref'}); 130 | } 131 | 132 | return $schemaObject; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/test.php: -------------------------------------------------------------------------------- 1 | json(…)` asserter. 23 | */ 24 | public function setJsonHandler(Configuration $configuration) 25 | { 26 | $jsonSchemaUriRetriever = new JsonSchema\UriRetriever(); 27 | 28 | foreach ($configuration->getJsonSchemaMountPoints() as $mountName => $mountValue) { 29 | $jsonSchemaUriRetriever->mount($mountName, $mountValue); 30 | } 31 | 32 | $self = $this; 33 | $jsonAsserter = null; 34 | 35 | $this 36 | ->getAssertionManager() 37 | ->setHandler( 38 | 'json', 39 | function ($json) use ($self, &$jsonAsserter, $jsonSchemaUriRetriever) { 40 | if (null === $jsonAsserter) { 41 | $jsonAsserter = new Asserter\Json($self->getAsserterGenerator()); 42 | $jsonAsserter->setJsonSchemaUriRetriever($jsonSchemaUriRetriever); 43 | } 44 | 45 | $jsonAsserter->setWithTest($self); 46 | 47 | return $jsonAsserter->setWith($json, null, null); 48 | } 49 | ); 50 | } 51 | 52 | /** 53 | * The `responsesMatch` asserter checks that a collection of responses are 54 | * valid regarding a collection of expected responses. 55 | */ 56 | public function responsesMatch(\Generator $responses, array $expectedResponses): self 57 | { 58 | foreach ($responses as $i => $response) { 59 | if (!isset($expectedResponses[$i])) { 60 | $this->boolean(true)->isTrue(); 61 | 62 | continue; 63 | } 64 | 65 | $expectedResponse = $expectedResponses[$i]; 66 | 67 | $this 68 | ->integer($response->statusCode) 69 | ->isIdenticalTo( 70 | $expectedResponse['statusCode'], 71 | 'The expected HTTP status code is:' . "\n" . 72 | ' ' . $expectedResponse['statusCode'] . "\n" . 73 | 'Received:' . "\n" . 74 | ' ' . $response->statusCode 75 | ); 76 | 77 | if (!empty($expectedResponse['mediaType'])) { 78 | $this 79 | ->array($response->headers) 80 | ->hasKey( 81 | 'content-type', 82 | 'The expected HTTP `Content-Type` is `' . 83 | $expectedResponse['mediaType']. 84 | '` but this header is absent from the response.' 85 | ) 86 | ->string['content-type'] 87 | ->isIdenticalTo( 88 | $expectedResponse['mediaType'], 89 | 'The expected HTTP `Content-Type` is:' . "\n" . 90 | ' ' . $expectedResponse['mediaType'] . "\n" . 91 | 'Received:' . "\n" . 92 | ' ' . $response->headers['content-type'] 93 | ); 94 | } 95 | 96 | if (!empty($expectedResponse['headers'])) { 97 | $headerAsserter = $this->array($response->headers); 98 | 99 | foreach ($expectedResponse['headers'] as $headerName => $headerValue) { 100 | $headerAsserter 101 | ->hasKey( 102 | $headerName, 103 | 'The expected value for the HTTP header `' . $headerName . '` is `' . 104 | $headerValue . 105 | '` but this header is absent from the response.' 106 | ) 107 | ->string[$headerName] 108 | ->isIdenticalTo( 109 | $headerValue, 110 | 'The expected value for the HTTP header `' . $headerName . '` is:' . "\n" . 111 | ' ' . $headerValue . "\n" . 112 | 'Received:' . "\n" . 113 | ' ' . $response->headers[$headerName] 114 | ); 115 | } 116 | } 117 | 118 | if (!empty($expectedResponse['body'])) { 119 | $this 120 | ->string($response->body) 121 | ->isIdenticalTo( 122 | $expectedResponse['body'], 123 | 'The expected response body does not match the received one:' . "\n" . 124 | new atoum\tools\diffs\variable($expectedResponse['body'], $response->body) 125 | ); 126 | } 127 | 128 | if (!empty($expectedResponse['schema'])) { 129 | $this 130 | ->json($response->body) 131 | ->fulfills($expectedResponse['schema']); 132 | } 133 | } 134 | 135 | return $this; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | atoum's extension logo 3 |

4 | 5 | # atoum/apiblueprint-extension [![Build Status](https://travis-ci.org/Hywan/atoum-apiblueprint-extension.svg?branch=master)](https://travis-ci.org/Hywan/atoum-apiblueprint-extension) 6 | 7 | **The candidates**: 8 | 9 | 1. [atoum](http://atoum.org/) is a PHP test framework, 10 | 2. [API Blueprint](https://apiblueprint.org/) is a high-level HTTP 11 | API description language. 12 | 13 | **The problem**: API Blueprint is only a text file. Easy to read for 14 | human, but a machine can't do anything with it. 15 | 16 | **The solution**: Compile API Blueprint files into executable tests. It 17 | works as any test written with the atoum API, and it works within the 18 | atoum ecosystem. Here is an overview of the workflow: 19 | 20 |

21 | Process overview 22 |

23 | 24 | In more details, here is what happens: 25 | 26 | 1. A finder iterates over `.apib` files, 27 | 2. For each file, it is parsed into an intermediate representation, 28 | 3. The intermediate representation is compiled to target “atoum 29 | tests”, 30 | 4. The fresh tests are pushed in the test queue of atoum, 31 | 5. atoum executes everything as usual. 32 | 33 | **The bonus**: A very simple script is provided to _render_ many API 34 | Blueprint files as a standalone HTML single-page file. 35 | 36 | ## Installation and configuration 37 | 38 | With [Composer](https://getcomposer.org/), to include this extension into 39 | your dependencies, you need to 40 | require 41 | [`atoum/apiblueprint-extension`](https://packagist.org/packages/atoum/apiblueprint-extension): 42 | 43 | ```sh 44 | $ composer require atoum/apiblueprint-extension '~0.2' 45 | ``` 46 | 47 | To enable the extension, the `.atoum.php` configuration file must be edited to add: 48 | 49 | ```php 50 | $extension = new atoum\apiblueprint\extension($script); 51 | $extension->addToRunner($runner); 52 | 53 | ``` 54 | 55 | ### Configure the finder 56 | 57 | Assuming the `.apib` files are located in the `./apiblueprints` 58 | directory, the following code adds this directory to the API Blueprint 59 | finder, compiles everything to tests, and enqueue them: 60 | 61 | ```php 62 | $extension->getAPIBFinder()->append(new FilesystemIterator('./apiblueprints')); 63 | $extension->compileAndEnqueue(); 64 | ``` 65 | 66 | ### Configure the location of JSON schemas when defined outside `.apib` files 67 | 68 | API Blueprint uses [JSON Schema](http://json-schema.org/) 69 | to 70 | [validate HTTP requests and responses](https://apiblueprint.org/documentation/advanced-tutorial.html#json-schema) when 71 | the message aims at being a valid JSON message. 72 | 73 | We recommend to define JSON schemas outside the `.apib` files for several reasons: 74 | 75 | * They can be versionned independently from the `.apib` files, 76 | * They can be used inside your application to validate incoming HTTP 77 | requests or outgoing HTTP responses, 78 | * They can be used by other tools. 79 | 80 | To do so, one must go through these 2 steps: 81 | 82 | 1. Mount a JSON schema directory with the help of the extension's 83 | configuration, 84 | 2. Use `{"$ref": "json-schema:///schema.json"}` in the [Schema 85 | section](https://apiblueprint.org/documentation/advanced-tutorial.html#json-schema). 86 | 87 | Example: 88 | 89 | 1. In the `.atoum.php` file where the extension is configured: 90 | 91 | ```php 92 | $extension->getConfiguration()->mountJsonSchemaDirectory('test', '/path/to/schemas/'); 93 | ``` 94 | 95 | 2. In the API Blueprint file: 96 | 97 | ```apib 98 | + Response 200 99 | 100 | + Schema 101 | 102 | {"$ref": "json-schema://test/api-foo/my-schema.json"} 103 | ``` 104 | 105 | where `test` is the “mount point name”, and 106 | `/api-foo/my-schema.json` is a valid JSON schema file located at 107 | `/path/to/schemas/api-foo/my-schema.json`. 108 | 109 | ## Testing 110 | 111 | Before running the test suites, the development dependencies must be installed: 112 | 113 | ```sh 114 | $ composer install 115 | ``` 116 | 117 | Then, to run all the test suites: 118 | 119 | ```sh 120 | $ composer test 121 | ``` 122 | 123 | ## Compliance with the API Blueprint specification 124 | 125 | This atoum extension implements the 126 | [API Blueprint specification](https://apiblueprint.org/documentation/specification.html). 127 | 128 | | Language features | Implemented? | 129 | |-----------------------------|--------------| 130 | | Metadata section | yes | 131 | | API name & overview section | yes | 132 | | Resource group section | yes | 133 | | Resource section | yes | 134 | | Resource model section | no (ignored) | 135 | | Schema section | yes | 136 | | Action section | yes | 137 | | Request section | yes | 138 | | Response section | yes | 139 | | URI parameters section | no (ignored) | 140 | | Attributes section | no (ignored) | 141 | | Headers section | yes | 142 | | Body section | yes | 143 | | Data Structures section | no (ignored) | 144 | | Relation section | no (ignored) | 145 | 146 | [Any help is welcomed](https://github.com/Hywan/atoum-apiblueprint-extension/issues/new)! 147 | 148 | # License 149 | 150 | Please, see the `LICENSE` file. This project uses the same license than atoum. 151 | -------------------------------------------------------------------------------- /bin/atoum-apiblueprint-render: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getLevel(); 18 | $class = ''; 19 | $parser = new Parser(); 20 | 21 | $blockContent = $parser->getHeaderContent($block); 22 | 23 | switch ($parser->getHeaderType($blockContent, $headerMatches)) { 24 | case Parser::HEADER_GROUP: 25 | $class = 'heading--type-group'; 26 | 27 | break; 28 | 29 | case Parser::HEADER_RESOURCE: 30 | $class = 'heading--type-resource'; 31 | 32 | break; 33 | 34 | case Parser::HEADER_ACTION: 35 | $class = 'heading--type-action'; 36 | 37 | break; 38 | 39 | default: 40 | if (1 === $block->getLevel()) { 41 | $class = 'heading--type-description'; 42 | } 43 | } 44 | 45 | foreach ($block->getData('attributes', []) as $key => $value) { 46 | $attributes[$key] = CommonMark\Util\Xml::escape($value, true); 47 | } 48 | 49 | $isFirstHeadingAtThisLevel = true; 50 | $previous = $block; 51 | $deltaPreviousLevel = 1; 52 | 53 | do { 54 | $previous = $previous->previous(); 55 | 56 | if ($previous instanceof CommonMark\Block\Element\Heading) { 57 | if ($previous->getLevel() < $block->getLevel()) { 58 | $isFirstHeadingAtThisLevel = true; 59 | $deltaPreviousLevel = 1; 60 | } else { 61 | $isFirstHeadingAtThisLevel = false; 62 | $deltaPreviousLevel = $previous->getLevel() - $block->getLevel() + 1; 63 | } 64 | 65 | break; 66 | } 67 | } while (null !== $previous); 68 | 69 | $id = 70 | (new UString($block->getStringContent())) 71 | ->toAscii() 72 | ->replace('/[^a-zA-Z0-9]+/', '-') 73 | ->toLowerCase() . 74 | '-' . 75 | (self::$idNumber++); 76 | 77 | return 78 | (true === $isFirstHeadingAtThisLevel && 1 === $block->getLevel() ? '' : '') . 79 | (false === $isFirstHeadingAtThisLevel ? str_repeat('', $deltaPreviousLevel) : '') . 80 | '
' . 81 | '<' . $tag . ' id="' . $id . '">' . 82 | $htmlRenderer->renderInlines($block->children()) . 83 | '' . 84 | '
'; 85 | } 86 | } 87 | 88 | class ParagraphRenderer implements CommonMark\Block\Renderer\BlockRendererInterface 89 | { 90 | public function render(CommonMark\Block\Element\AbstractBlock $block, CommonMark\ElementRendererInterface $htmlRenderer, $inTightList = false) 91 | { 92 | if (true === $inTightList) { 93 | return $htmlRenderer->renderInlines($block->children()); 94 | } 95 | 96 | $attributes = []; 97 | 98 | foreach ($block->getData('attributes', []) as $key => $value) { 99 | $attributes[$key] = Xml::escape($value, true); 100 | } 101 | 102 | $metadata = true; 103 | 104 | foreach ($block->children() as $child) { 105 | if ($child instanceof CommonMark\Inline\Element\Text && 106 | 0 === preg_match('/^([^:]+):(.*)$/', $child->getContent(), $match)) { 107 | $metadata = false; 108 | 109 | break; 110 | } 111 | } 112 | 113 | if (true === $metadata) { 114 | $attributes['class'] = 'metadata'; 115 | } 116 | 117 | return new CommonMark\HtmlElement('p', $attributes, $htmlRenderer->renderInlines($block->children())); 118 | } 119 | } 120 | 121 | 122 | function stdout(string $message) 123 | { 124 | file_put_contents('php://stdout', $message); 125 | } 126 | 127 | function stderr(string $message) 128 | { 129 | file_put_contents('php://stderr', $message); 130 | } 131 | 132 | function usage() 133 | { 134 | stdout( 135 | 'Usage: ' . $_SERVER['argv'][0] . ' [] []' . "\n" . 136 | 'Options: ' . "\n" . 137 | ' -t, --title: Documentation title.' . "\n" . 138 | ' -h, --help : This help.' . "\n" 139 | ); 140 | } 141 | 142 | function render(Finder $finder, string $title) 143 | { 144 | $environment = CommonMark\Environment::createCommonMarkEnvironment(); 145 | $environment->addBlockRenderer(CommonMark\Block\Element\Heading::class, new HeadingRenderer()); 146 | $environment->addBlockRenderer(CommonMark\Block\Element\Paragraph::class, new ParagraphRenderer()); 147 | 148 | $configuration = ['html_input' => 'escape']; 149 | $compiler = new CommonMark\CommonMarkConverter($configuration, $environment); 150 | 151 | $body = (function () use ($finder, $compiler) { 152 | foreach ($finder as $file) { 153 | yield '
' . "\n"; 154 | yield $compiler->convertToHtml(file_get_contents($file->getPathname())); 155 | yield '
' . "\n"; 156 | } 157 | })(); 158 | 159 | (function () use ($title, $body) { 160 | require dirname(__DIR__) . '/res/template/html.php'; 161 | })(); 162 | } 163 | 164 | if (count($_SERVER['argv']) === 1) { 165 | usage(); 166 | 167 | exit(1); 168 | } 169 | 170 | $arguments = array_slice($_SERVER['argv'], 1); 171 | $finder = new Finder(); 172 | $hasOnePath = false; 173 | $title = '(untitled)'; 174 | $optionPointer = null; 175 | $optionExpectsValue = false; 176 | 177 | foreach ($arguments as $i => $argument) { 178 | switch ($argument) { 179 | case '-t': 180 | case '--title': 181 | $optionPointer = &$title; 182 | $optionExpectsValue = true; 183 | 184 | break; 185 | 186 | case '-h': 187 | case '--help': 188 | usage(); 189 | 190 | exit(1); 191 | 192 | default: 193 | if (true === $optionExpectsValue) { 194 | $optionPointer = $argument; 195 | $optionExpectsValue = false; 196 | unset($optionPointer); 197 | 198 | break; 199 | } 200 | 201 | if (0 !== preg_match('/(--?.+)/', $argument, $match)) { 202 | stderr('Unrecognized option `' . $match[1] . '`.' . "\n"); 203 | } else { 204 | $hasOnePath = true; 205 | $finder->append(new FilesystemIterator($argument)); 206 | } 207 | } 208 | } 209 | 210 | if (false === $hasOnePath) { 211 | stderr('The path is missing.' . "\n\n"); 212 | usage(); 213 | 214 | exit(2); 215 | } 216 | 217 | render($finder, $title); 218 | -------------------------------------------------------------------------------- /src/Target.php: -------------------------------------------------------------------------------- 1 | write('namespace atoum\apiblueprint\generated;' . "\n\n"); 16 | 17 | $testSuiteName = $document->apiName; 18 | 19 | if (empty($testSuiteName)) { 20 | $testSuiteName = 'Unknown' . sha1(serialize($document)); 21 | } else { 22 | $testSuiteName = $this->stringToPHPIdentifier($testSuiteName, false); 23 | } 24 | 25 | $outputFile->write( 26 | 'class ' . $testSuiteName . ' extends \atoum\apiblueprint\test' . "\n" . 27 | '{' . "\n" . 28 | ' protected $_host = null;' . "\n\n" . 29 | ' public function beforeTestMethod($testMethod)' . "\n" . 30 | ' {' . "\n" . 31 | ' $this->_host = \'' . $document->metadata['host'] . '\';' . "\n" . 32 | ' }' 33 | ); 34 | 35 | foreach ($document->resources as $i => $resource) { 36 | $resourceName = $resource->name; 37 | 38 | if (empty($resourceName)) { 39 | $resourceName = 'test resource unknonwn' . $i; 40 | } else { 41 | $resourceName = 'test resource ' . $this->stringToPHPIdentifier($resourceName); 42 | } 43 | 44 | $this->compileResource($resource, $resourceName, $outputFile); 45 | } 46 | 47 | $outputFile->write("\n" . '}'); 48 | } 49 | 50 | public function compileResource(IR\Resource $resource, string $resourceName, file $outputFile) 51 | { 52 | if (empty($resource->actions)) { 53 | $outputFile->write( 54 | "\n\n" . 55 | ' public function ' . $resourceName . '()' . "\n" . 56 | ' {' . "\n" . 57 | ' $this->skip(\'No action for the resource `' . $resource->name . '`.\');' . "\n" . 58 | ' }' 59 | ); 60 | 61 | return; 62 | } 63 | 64 | foreach ($resource->actions as $i => $action) { 65 | $actionName = $action->name; 66 | 67 | if (empty($actionName)) { 68 | $actionName = 'action unknown' . $i; 69 | } else { 70 | $actionName = 'action ' . $this->stringToPHPIdentifier($actionName); 71 | } 72 | 73 | $this->compileAction($action, $actionName, $resource, $resourceName, $outputFile); 74 | } 75 | } 76 | 77 | public function compileAction(IR\Action $action, string $actionName, IR\Resource $resource, string $resourceName, file $outputFile) 78 | { 79 | static $_defaultPayload = null; 80 | 81 | if (null === $_defaultPayload) { 82 | $_defaultPayload = new IR\Payload(); 83 | } 84 | 85 | if (empty($action->messages)) { 86 | $outputFile->write( 87 | "\n\n" . 88 | ' public function ' . $resourceName . ' ' . $actionName . '()' . "\n" . 89 | ' {' . "\n" . 90 | ' $this->skip(\'Action `' .$action->name . '` for the resource `' . $resource->name . '` has no message.\');' . "\n" . 91 | ' }' 92 | ); 93 | } else { 94 | foreach ($this->groupMessagesByTransactions($action) as $i => $transaction) { 95 | $outputFile->write( 96 | "\n\n" . 97 | ' public function ' . $resourceName . ' ' . $actionName . ' transaction ' . $i . '()' . "\n" . 98 | ' {' . "\n" . 99 | ' $requester = new \atoum\apiblueprint\Http\Requester();' . "\n" . 100 | ' $expectedResponses = [];' . "\n\n" 101 | ); 102 | 103 | foreach ($transaction as $message) { 104 | if ($message instanceof IR\Request) { 105 | $payload = $message->payload ?? $_defaultPayload; 106 | $uri = $action->uriTemplate; 107 | 108 | if (empty($uri)) { 109 | $uri = $resource->uriTemplate; 110 | } 111 | 112 | $outputFile->write( 113 | ' $requester->addRequest(' . "\n" . 114 | ' \'' . $action->requestMethod . '\',' . "\n" . 115 | ' $this->_host . \'' . $uri . '\',' . "\n" . 116 | ' [' . "\n" . 117 | $this->arrayAsStringRepresentation($payload->headers, ' ') . 118 | ' ]' . "\n" . 119 | ' );' . "\n" 120 | ); 121 | } elseif ($message instanceof IR\Response) { 122 | $payload = $message->payload ?? $_defaultPayload; 123 | 124 | $outputFile->write( 125 | ' $expectedResponses[] = [' . "\n" . 126 | ' \'statusCode\' => ' . $message->statusCode . ',' . "\n" . 127 | ' \'mediaType\' => \'' . $message->mediaType . '\',' . "\n" . 128 | ' \'headers\' => [' . "\n" . 129 | $this->arrayAsStringRepresentation($payload->headers, ' ') . 130 | ' ],' . "\n" . 131 | ' \'body\' => ' . var_export(trim($payload->body), true) . ',' . "\n" . 132 | ' \'schema\' => ' . var_export($payload->schema, true) . ',' . "\n" . 133 | ' ];' . "\n" 134 | ); 135 | } 136 | } 137 | 138 | $outputFile->write( 139 | "\n" . 140 | ' $this->responsesMatch($requester->send(), $expectedResponses);' . "\n" . 141 | ' }' 142 | ); 143 | } 144 | } 145 | } 146 | 147 | protected function arrayAsStringRepresentation(array $array, string $linePrefix = ''): string 148 | { 149 | $out = ''; 150 | 151 | foreach ($array as $key => $value) { 152 | $out .= $linePrefix . var_export($key, true) . ' => ' . var_export($value, true) . ',' . "\n"; 153 | } 154 | 155 | return $out; 156 | } 157 | 158 | public function groupMessagesByTransactions(IR\Action $action): \Generator 159 | { 160 | $transaction = $action->messages; 161 | $transactionHasRequest = false; 162 | 163 | foreach ($transaction as $message) { 164 | if ($message instanceof IR\Request) { 165 | $transactionHasRequest = true; 166 | 167 | break; 168 | } 169 | } 170 | 171 | if (false === $transactionHasRequest) { 172 | array_unshift($transaction, new IR\Request()); 173 | } 174 | 175 | yield $transaction; 176 | } 177 | 178 | public function stringToPHPIdentifier(string $string, bool $toLowerCase = true): string 179 | { 180 | $identifier = (new UString($string))->toAscii()->replace('/[^a-zA-Z0-9_\x80-\xff]/', ' '); 181 | 182 | if (true === $toLowerCase) { 183 | $identifier->toLowerCase(); 184 | } 185 | 186 | return trim((string) $identifier, ' '); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /res/example.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | HOST: https://api.example.com 3 | 4 | # API Title 5 | [Markdown](http://daringfireball.net/projects/markdown/syntax) **formatted** description. 6 | 7 | ## Subtitle 8 | Also Markdown *formatted*. This also includes automatic "smartypants" formatting -- hooray! 9 | 10 | > "A quote from another time and place" 11 | 12 | Another paragraph. Code sample: 13 | 14 | ```http 15 | Authorization: bearer 5262d64b892e8d4341000001 16 | ``` 17 | 18 | And some code with no highlighting: 19 | 20 | ```no-highlight 21 | Foo bar baz 22 | ``` 23 | 24 | 1. A list 25 | 2. Of items 26 | 3. Can be 27 | 4. Very useful 28 | 29 | Here is a table: 30 | 31 | ID | Name | Description 32 | --:| ---- | ----------- 33 | 1 | Foo | I am a foo. 34 | 8 | Bar | I am a bar. 35 | 15 | Baz | I am a baz. 36 | 37 | ::: note 38 | ## Extensions 39 | Some non-standard Markdown extensions are also supported, such as this informational container, which can also contain **formatting**. Features include: 40 | 41 | * Informational block fenced with `::: note` and `:::` 42 | * Warning block fenced with `::: warning` and `:::` 43 | * [x] GitHub-style checkboxes using `[x]` and `[ ]` 44 | * Emoji support :smile: :ship: :cake: using `:smile:` ([cheat sheet](http://www.emoji-cheat-sheet.com/)) 45 | 46 | These extensions may change in the future as the [CommonMark specification](http://spec.commonmark.org/) defines a [standard extension syntax](https://github.com/jgm/CommonMark/wiki/Proposed-Extensions). 47 | ::: 48 | 49 | # Data Structures 50 | 51 | ## NoteData 52 | + id: 1 (required, number) - Unique identifier 53 | + title: Grocery list (required) - Single line description 54 | + body: Buy milk - Full description of the note which supports Markdown. 55 | 56 | ## NoteList (array) 57 | + (NoteData) 58 | 59 | # Group Notes 60 | Group description (also with *Markdown*) 61 | 62 | ## Important Info 63 | Descriptions may also contain sub-headings and **more Markdown**. 64 | 65 | ## Note List [/notes] 66 | Note list description 67 | 68 | + Even 69 | + More 70 | + Markdown 71 | 72 | ### Get Notes [GET] 73 | Get a list of notes. 74 | 75 | + Response 200 (application/json) 76 | 77 | + Headers 78 | 79 | X-Request-ID: f72fc914 80 | X-Response-Time: 4ms 81 | 82 | + Attributes (NoteList) 83 | 84 | ### Create New Note [POST] 85 | Create a new note using a title and an optional content body. 86 | 87 | + Request with body (application/json) 88 | 89 | + Body 90 | 91 | { 92 | "title": "My new note", 93 | "body": "This is the body" 94 | } 95 | 96 | + Response 201 97 | 98 | + Response 400 (application/json) 99 | 100 | + Body 101 | 102 | { 103 | "error": "Invalid title" 104 | } 105 | 106 | + Request without body (application/json) 107 | 108 | + Body 109 | 110 | { 111 | "title": "My new note" 112 | } 113 | 114 | + Response 201 115 | 116 | + Response 400 (application/json) 117 | 118 | + Body 119 | 120 | { 121 | "error": "Invalid title" 122 | } 123 | 124 | ## Note [/notes/{id}{?body}] 125 | Note description 126 | 127 | + Parameters 128 | 129 | + id: `68a5sdf67` (required, string) - The note ID 130 | 131 | ### Get Note [GET] 132 | Get a single note. 133 | 134 | + Parameters 135 | 136 | + body: `false` (boolean) - Set to `false` to exclude note body content. 137 | 138 | + Response 200 (application/json) 139 | 140 | + Headers 141 | 142 | X-Request-ID: f72fc914 143 | X-Response-Time: 4ms 144 | 145 | + Attributes (NoteData) 146 | 147 | + Response 404 (application/json) 148 | 149 | + Headers 150 | 151 | X-Request-ID: f72fc914 152 | X-Response-Time: 4ms 153 | 154 | + Body 155 | 156 | { 157 | "error": "Note not found" 158 | } 159 | 160 | ### Update a Note [PUT] 161 | Update a single note by setting the title and/or body. 162 | 163 | #### Caution 164 | If the value for `title` or `body` is `null` or `undefined`, then the corresponding value is not modified on the server. However, if you send an empty string instead then it will **permanently overwrite** the original value. 165 | 166 | + Request (application/json) 167 | 168 | + Body 169 | 170 | { 171 | "title": "Grocery List (Safeway)" 172 | } 173 | 174 | + Response 200 (application/json) 175 | 176 | + Headers 177 | 178 | X-Request-ID: f72fc914 179 | X-Response-Time: 4ms 180 | 181 | + Attributes (NoteData) 182 | 183 | + Response 404 (application/json) 184 | 185 | + Headers 186 | 187 | X-Request-ID: f72fc914 188 | X-Response-Time: 4ms 189 | 190 | + Body 191 | 192 | { 193 | "error": "Note not found" 194 | } 195 | 196 | + Request delete body (application/json) 197 | 198 | + Body 199 | 200 | { 201 | "body": "" 202 | } 203 | 204 | + Response 200 (application/json) 205 | 206 | + Headers 207 | 208 | X-Request-ID: f72fc914 209 | X-Response-Time: 4ms 210 | 211 | + Attributes (NoteData) 212 | 213 | + Response 404 (application/json) 214 | 215 | + Headers 216 | 217 | X-Request-ID: f72fc914 218 | X-Response-Time: 4ms 219 | 220 | + Body 221 | 222 | { 223 | "error": "Note not found" 224 | } 225 | 226 | ### Delete a Note [DELETE] 227 | Delete a single note 228 | 229 | + Response 204 230 | 231 | + Response 404 (application/json) 232 | 233 | + Headers 234 | 235 | X-Request-ID: f72fc914 236 | X-Response-Time: 4ms 237 | 238 | + Body 239 | 240 | { 241 | "error": "Note not found" 242 | } 243 | 244 | # Group Users 245 | Group description 246 | 247 | ## User List [/users{?name,joinedBefore,joinedAfter,sort,limit}] 248 | A list of users 249 | 250 | + Parameters 251 | 252 | + name: `alice` (string, optional) - Search for a user by name 253 | + joinedBefore: `2011-01-01` (string, optional) - Search by join date 254 | + joinedAfter: `2011-01-01` (string, optional, ) - Search by join date 255 | + sort: `joined` (string, optional) - Which field to sort by 256 | + Default: `name` 257 | + Members 258 | + `name` 259 | + `joined` 260 | + `-joined` 261 | + `age` 262 | + `-age` 263 | + `location` 264 | + `-location` 265 | + `plan` 266 | + `-plan` 267 | + limit: `25` (integer, optional) - The maximum number of users to return, up to `50` 268 | + Default: `10` 269 | 270 | ### Get users [GET] 271 | Get a list of users. Example: 272 | 273 | ```no-highlight 274 | https://api.mywebsite.com/users?sort=joined&limit=5 275 | ``` 276 | 277 | + Response 200 (application/json) 278 | 279 | + Body 280 | 281 | [ 282 | { 283 | "name": "alice", 284 | "image": "http://example.com/alice.jpg", 285 | "joined": "2013-11-01" 286 | }, 287 | { 288 | "name": "bob", 289 | "image": "http://example.com/bob.jpg", 290 | "joined": "2013-11-02" 291 | } 292 | ] 293 | 294 | # Group Tags and Tagging Long Title 295 | Get or set tags on notes 296 | 297 | ## GET /tags 298 | Get a list of bars 299 | 300 | + Response 200 (application/json) 301 | 302 | ["tag1", "tag2", "tag3"] 303 | 304 | ## Get one tag [/tags/{id}] 305 | Get a single tag 306 | 307 | + Parameters 308 | + id - Unique tag identifier 309 | 310 | ### GET 311 | 312 | + Response 200 313 | -------------------------------------------------------------------------------- /res/template/html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Documentation for an HTTP API 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 217 | 218 | 219 |
220 | 229 | 230 |
231 |
232 |

233 |
234 | 235 | 242 |
243 |
244 | 245 | 293 | 294 | 295 | -------------------------------------------------------------------------------- /res/overview.svg: -------------------------------------------------------------------------------- 1 | 2 |
example.apib
example.apib
My awesome API
My awesome API
Lorem ipsum dolor sit amet.
Lorem ipsum dolor sit amet.
Group seller [/purchases]
Group seller [/purchases]
Etiam semper accumsan mi.
Etiam semper accumsan mi.
Phasellus pretium nec dui ut tempor.
Phasellus pretium nec dui ut tempor.
Cras sodales erat arcu, at suscipit.
Cras sodales erat arcu, at suscipit.
List all of them [GET]
List all of them [GET]
My_awesome_API.php
My_awesome_API.php
atoum runner
atoum runner
atoum extension
atoum extension
compiles
compiles
test verdict
test verdict
finds all .apib files
finds all .apib files
runs
runs
class My_awesome_API extends test
class My_awesome_API extends test
}
}
public function test_xxx() { … }
public function test_xxx() { … }
public function test_yyy() { … }
public function test_yyy() { … }
{
{
public function test_zzz() { … }
public function test_zzz() { … }
-------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | _state = self::STATE_BEFORE_DOCUMENT; 45 | $this->_currentDocument = null; 46 | $this->_currentNode = null; 47 | $this->_currentIsEntering = null; 48 | 49 | $markdownParser = static::getMarkdownParser(); 50 | $this->_walker = $markdownParser->parse($apib)->walker(); 51 | 52 | //while ($event = $this->next()); exit; 53 | 54 | $this->parseStructure(); 55 | 56 | return $this->_currentDocument; 57 | } 58 | 59 | protected function next(bool $expectEOF = false) 60 | { 61 | $this->debug('>> ' . __FUNCTION__ . "\n"); 62 | 63 | $event = $this->_walker->next(); 64 | 65 | if (null === $event) { 66 | if (false === $expectEOF) { 67 | throw new Exception\ParserEOF('End of the document has been reached unexpectedly.'); 68 | } else { 69 | return null; 70 | } 71 | } 72 | 73 | $this->debug(($event->isEntering() ? 'Entering' : 'Leaving') . ' a ' . get_class($event->getNode()) . ' node' . "\n"); 74 | 75 | $this->_currentNode = $event->getNode(); 76 | $this->_currentIsEntering = $event->isEntering(); 77 | 78 | return $event; 79 | } 80 | 81 | protected function peek() 82 | { 83 | $event = $this->_walker->next(); 84 | 85 | if (null !== $event) { 86 | $this->debug('?? ' . ($event->isEntering() ? 'Entering' : 'Leaving') . ' a ' . get_class($event->getNode()) . ' node' . "\n"); 87 | } else { 88 | $this->debug('?? null'); 89 | } 90 | 91 | $this->debug('<< ' . ($this->_currentIsEntering ? 'Entering' : 'Leaving') . ' a ' . get_class($this->_currentNode) . ' node' . "\n"); 92 | 93 | $this->_walker->resumeAt($this->_currentNode, $this->_currentIsEntering); 94 | $this->_walker->next(); // move to the state of the current node. 95 | 96 | return $event; 97 | } 98 | 99 | protected function parseStructure() 100 | { 101 | $this->debug('>> ' . __FUNCTION__ . "\n"); 102 | 103 | while ($event = $this->next(true)) { 104 | $node = $event->getNode(); 105 | $isEntering = $event->isEntering(); 106 | 107 | $this->debug('%% state = ' . $this->_state . "\n"); 108 | 109 | // End of the document. 110 | if (self::STATE_AFTER_DOCUMENT === $this->_state) { 111 | return; 112 | } 113 | // Beginning of the document. 114 | elseif (self::STATE_BEFORE_DOCUMENT === $this->_state && 115 | $node instanceof Block\Document && $isEntering) { 116 | $this->parseDocument($node); 117 | } 118 | // Entering heading level 1: Either group section, resource 119 | // section, or a document description. 120 | elseif ($node instanceof Block\Heading && $isEntering && 121 | 1 === $node->getLevel()) { 122 | $this->parseDescriptionOrGroupOrResource($node); 123 | } 124 | } 125 | } 126 | 127 | protected function parseDocument($node) 128 | { 129 | $this->debug('>> ' . __FUNCTION__ . "\n"); 130 | 131 | $this->_currentDocument = new IR\Document(); 132 | 133 | $event = $this->peek(); 134 | 135 | // The document is empty. 136 | if ($event->getNode() instanceof Block\Document && false === $event->isEntering()) { 137 | $this->_state = self::STATE_AFTER_DOCUMENT; 138 | 139 | return; 140 | } 141 | 142 | // The document might have metadata. 143 | if ($event->getNode() instanceof Block\Paragraph && true === $event->isEntering()) { 144 | $this->next(); 145 | 146 | do { 147 | $event = $this->next(); 148 | 149 | if ($event->getNode() instanceof Block\Paragraph && false === $event->isEntering()) { 150 | break; 151 | } 152 | 153 | if ($event->getNode() instanceof Inline\Text && 154 | 0 !== preg_match('/^([^:]+):(.*)$/', $event->getNode()->getContent(), $match)) { 155 | $this->_currentDocument->metadata[mb_strtolower(trim($match[1]))] = trim($match[2]); 156 | } 157 | } while(true); 158 | } 159 | 160 | $this->_state = self::STATE_ROOT; 161 | 162 | return; 163 | } 164 | 165 | protected function parseDescriptionOrGroupOrResource(Block\Heading $node) 166 | { 167 | $this->debug('>> ' . __FUNCTION__ . "\n"); 168 | 169 | $headerContent = $this->getHeaderContent($node); 170 | 171 | switch ($this->getHeaderType($headerContent, $matches)) { 172 | case self::HEADER_GROUP: 173 | $group = new IR\Group(); 174 | $group->name = $matches[1]; 175 | 176 | $this->_currentDocument->groups[] = $group; 177 | 178 | $level = $node->getLevel(); 179 | 180 | while ($event = $this->peek()) { 181 | $nextNode = $event->getNode(); 182 | $isEntering = $event->isEntering(); 183 | 184 | if ($nextNode instanceof Block\Heading && $isEntering) { 185 | if ($nextNode->getLevel() <= $level) { 186 | return; 187 | } 188 | 189 | $this->next(); 190 | 191 | $nextHeaderContent = $this->getHeaderContent($nextNode); 192 | 193 | if (self::HEADER_RESOURCE === $this->getHeaderType($nextHeaderContent, $nextMatches)) { 194 | $this->parseResource( 195 | $nextNode, 196 | $group, 197 | $nextMatches['name'] ?? '', 198 | $nextMatches['uriTemplate'] ?? '' 199 | ); 200 | } 201 | } else { 202 | $this->next(); 203 | } 204 | } 205 | 206 | break; 207 | 208 | case self::HEADER_RESOURCE: 209 | $this->parseResource( 210 | $node, 211 | $this->_currentDocument, 212 | $matches['name'], 213 | $matches['uriTemplate'] 214 | ); 215 | 216 | break; 217 | 218 | case self::HEADER_UNKNOWN: 219 | if (empty($this->_currentDocument->apiName)) { 220 | $this->_currentDocument->apiName = $headerContent; 221 | } 222 | 223 | break; 224 | } 225 | } 226 | 227 | protected function parseResource(Block\Heading $node, $parent, string $name, string $uriTemplate) 228 | { 229 | $this->debug('>> ' . __FUNCTION__ . "\n"); 230 | 231 | $resource = new IR\Resource(); 232 | $resource->name = trim($name); 233 | $resource->uriTemplate = strtolower(trim($uriTemplate)); 234 | 235 | $parent->resources[] = $resource; 236 | 237 | $level = $node->getLevel(); 238 | 239 | while ($event = $this->peek()) { 240 | $nextNode = $event->getNode(); 241 | $isEntering = $event->isEntering(); 242 | 243 | if ($nextNode instanceof Block\Heading && $isEntering) { 244 | if ($nextNode->getLevel() <= $level) { 245 | return; 246 | } 247 | 248 | $this->next(); 249 | 250 | $nextHeaderContent = $this->getHeaderContent($nextNode); 251 | 252 | if (self::HEADER_ACTION === $this->getHeaderType($nextHeaderContent, $headerMatches)) { 253 | $this->parseAction( 254 | $nextNode, 255 | $resource, 256 | $headerMatches['name'] ?? '', 257 | $headerMatches['requestMethod1'] ?? $headerMatches['requestMethod2'] ?? '', 258 | $headerMatches['uriTemplate'] ?? '' 259 | ); 260 | } 261 | } else { 262 | $this->next(); 263 | } 264 | } 265 | } 266 | 267 | protected function parseAction( 268 | Block\Heading $node, 269 | IR\Resource $resource, 270 | string $name, 271 | string $requestMethod, 272 | string $uriTemplate 273 | ) { 274 | $this->debug('>> ' . __FUNCTION__ . "\n"); 275 | 276 | $action = new IR\Action(); 277 | $action->name = trim($name); 278 | $action->requestMethod = strtoupper(trim($requestMethod)); 279 | $action->uriTemplate = strtolower(trim($uriTemplate)); 280 | 281 | $resource->actions[] = $action; 282 | 283 | while ($event = $this->peek()) { 284 | $nextNode = $event->getNode(); 285 | $isEntering = $event->isEntering(); 286 | 287 | if ($nextNode instanceof Block\Heading && $isEntering) { 288 | return; 289 | } 290 | 291 | $this->next(); 292 | 293 | if ($nextNode instanceof Block\ListBlock && $isEntering) { 294 | while($event = $this->peek()) { 295 | $nextNodeInListBlock = $event->getNode(); 296 | $isEntering = $event->isEntering(); 297 | 298 | if ($nextNodeInListBlock instanceof Block\ListBlock && !$isEntering) { 299 | return; 300 | } 301 | 302 | $this->next(); 303 | 304 | if ($nextNodeInListBlock instanceof Block\ListItem && $isEntering) { 305 | $event = $this->peek(); 306 | 307 | $nextNodeInListItem = $event->getNode(); 308 | $isEntering = $event->isEntering(); 309 | 310 | if ($nextNodeInListItem instanceof Block\Paragraph && $isEntering) { 311 | $this->next(); 312 | 313 | // Move to the end of the paragraph. 314 | while( 315 | (($event = $this->next()) ?? false) && 316 | !($event->getNode() instanceof Block\Paragraph && false === $event->isEntering()) 317 | ); 318 | 319 | $actionContent = trim($nextNodeInListItem->getStringContent()); 320 | $actionType = $this->getActionType($actionContent, $actionMatches); 321 | 322 | if (self::ACTION_REQUEST === $actionType || 323 | self::ACTION_RESPONSE === $actionType) { 324 | $requestOrResponse = null; 325 | 326 | if (self::ACTION_REQUEST === $actionType) { 327 | $request = new IR\Request(); 328 | $request->name = trim($actionMatches['name'] ?? ''); 329 | $request->mediaType = trim($actionMatches['mediaType'] ?? ''); 330 | 331 | $action->messages[] = $request; 332 | 333 | $requestOrResponse = $request; 334 | } else { 335 | $response = new IR\Response(); 336 | $response->mediaType = trim($actionMatches['mediaType'] ?? ''); 337 | 338 | if (!isset($actionMatches['statusCode']) || empty($actionMatches['statusCode'])) { 339 | $response->statusCode = 200; 340 | } else { 341 | $response->statusCode = intval($actionMatches['statusCode']); 342 | } 343 | 344 | $action->messages[] = $response; 345 | 346 | $requestOrResponse = $response; 347 | } 348 | 349 | $event = $this->peek(); 350 | $payloadNode = $event->getNode(); 351 | $payloadIsEntering = $event->isEntering(); 352 | 353 | // Assume the paragraph is the description. 354 | if ($payloadNode instanceof Block\Paragraph && $payloadIsEntering) { 355 | $this->next(); 356 | $requestOrResponse->description = $payloadNode->getStringContent(); 357 | 358 | // But maybe it is wrong. So let's 359 | // peek the next node and see what it 360 | // is. 361 | $event = $this->peek(); 362 | $payloadNode = $event->getNode(); 363 | $payloadIsEntering = $event->isEntering(); 364 | } 365 | 366 | // There is a list of payloads. 367 | if ($payloadNode instanceof Block\ListBlock && $payloadIsEntering) { 368 | $this->next(); 369 | $this->parsePayload($payloadNode, $requestOrResponse); 370 | } 371 | 372 | // If there is a description and no 373 | // payload, then the description is the 374 | // `Body` payload. 375 | if (!empty($requestOrResponse->description) && null === $requestOrResponse->payload) { 376 | $payload = new IR\Payload(); 377 | $payload->body = $requestOrResponse->description; 378 | $requestOrResponse->description = ''; 379 | $requestOrResponse->payload = $payload; 380 | } 381 | } 382 | } 383 | } 384 | } 385 | } 386 | } 387 | } 388 | 389 | protected function parsePayload($node, IR\Message $requestOrResponse) 390 | { 391 | $this->debug('>> ' . __FUNCTION__ . "\n"); 392 | 393 | // The payload is a paragraph representing the body. 394 | if ($node instanceof Block\Paragraph) { 395 | $payload = new IR\Payload(); 396 | $payload->body = $node->getStringContent(); 397 | 398 | $requestOrResponse->payload = $payload; 399 | 400 | // Move to the end of the paragraph. 401 | while( 402 | (($event = $this->next()) ?? false) && 403 | !($event->getNode() instanceof Block\Paragraph && false === $event->isEntering()) 404 | ); 405 | } elseif ($node instanceof Block\ListBlock) { 406 | $payload = new IR\Payload(); 407 | $requestOrResponse->payload = $payload; 408 | 409 | while($event = $this->next()) { 410 | $nextNodeInListBlock = $event->getNode(); 411 | $isEntering = $event->isEntering(); 412 | 413 | if ($nextNodeInListBlock instanceof Block\ListBlock && !$isEntering) { 414 | return; 415 | } 416 | 417 | if ($nextNodeInListBlock instanceof Block\ListItem && $isEntering) { 418 | $event = $this->peek(); 419 | 420 | $nextNodeInListItem = $event->getNode(); 421 | $isEntering = $event->isEntering(); 422 | 423 | if ($nextNodeInListItem instanceof Block\Paragraph && $isEntering) { 424 | $this->next(); 425 | 426 | // Move to the end of the paragraph. 427 | while( 428 | (($event = $this->next()) ?? false) && 429 | !($event->getNode() instanceof Block\Paragraph && false === $event->isEntering()) 430 | ); 431 | 432 | $payloadContent = trim($nextNodeInListItem->getStringContent()); 433 | $payloadType = $this->getPayloadType($payloadContent, $payloadMatches); 434 | 435 | switch ($payloadType) { 436 | case self::PAYLOAD_BODY: 437 | case self::PAYLOAD_SCHEMA: 438 | $event = $this->peek(); 439 | $bodyOrSchemaNode = $event->getNode(); 440 | $bodyOrSchemaIsEntering = $event->isEntering(); 441 | 442 | if (($bodyOrSchemaNode instanceof Block\Paragraph && $bodyOrSchemaIsEntering) || 443 | ($bodyOrSchemaNode instanceof Block\FencedCode && $bodyOrSchemaIsEntering)) { 444 | $this->next(); 445 | 446 | $bodyOrSchemaContent = $bodyOrSchemaNode->getStringContent(); 447 | 448 | if (self::PAYLOAD_BODY === $payloadType) { 449 | $payload->body = $bodyOrSchemaContent; 450 | } else { 451 | $payload->schema = $bodyOrSchemaContent; 452 | } 453 | } 454 | 455 | break; 456 | 457 | case self::PAYLOAD_HEADERS: 458 | $event = $this->peek(); 459 | $headersNode = $event->getNode(); 460 | $headersIsEntering = $event->isEntering(); 461 | 462 | if ($headersNode instanceof Block\Paragraph && $headersIsEntering) { 463 | $this->next(); 464 | 465 | $payload->headers = array_merge( 466 | $payload->headers, 467 | Http\Response::parseRawHeaders($headersNode->getStringContent()) 468 | ); 469 | } 470 | 471 | break; 472 | 473 | case self::PAYLOAD_ATTRIBUTES: 474 | throw new \RuntimeException('Payload Attributes are not implemented yet.'); 475 | 476 | break; 477 | } 478 | } 479 | } 480 | } 481 | } 482 | } 483 | 484 | public function getHeaderType(string $headerContent, &$matches = []): int 485 | { 486 | // Resource group section. 487 | if (0 !== preg_match('/^Group\h+([^\[\]\(\)]+)/', $headerContent, $matches)) { 488 | return self::HEADER_GROUP; 489 | } 490 | 491 | // Resource section. 492 | if (0 !== preg_match('/^(?[^\[]+)\[(?\/[^\]]+)\]/', $headerContent, $matches)) { 493 | return self::HEADER_RESOURCE; 494 | } 495 | 496 | // Action section. 497 | if (0 !== preg_match('/(?:^(?[A-Z]+)$)|(?:^(?[^\[]+)\[(?[A-Z]+)(?:\h+(?\/[^\]]+))?\])/', $headerContent, $matches)) { 498 | if (empty($matches['requestMethod1'])) { 499 | $matches['requestMethod1'] = null; 500 | } 501 | 502 | return self::HEADER_ACTION; 503 | } 504 | 505 | // API name. 506 | return self::HEADER_UNKNOWN; 507 | } 508 | 509 | public function getHeaderContent(Block\Heading $node): string 510 | { 511 | return trim($node->getStringContent() ?? ''); 512 | } 513 | 514 | public function getActionType(string $actionContent, &$matches = []): int 515 | { 516 | // Request. 517 | if (0 !== preg_match('/^Request(?\h+[^\(]*)?(?:\((?[^\)]+)\))?/', $actionContent, $matches)) { 518 | return self::ACTION_REQUEST; 519 | } 520 | 521 | // Response. 522 | if (0 !== preg_match('/^Response(?\h+\d+)?(?:\h+\((?[^\)]+)\))?/', $actionContent, $matches)) { 523 | return self::ACTION_RESPONSE; 524 | } 525 | 526 | // Unknown. 527 | return self::ACTION_UNKNOWN; 528 | } 529 | 530 | protected function getPayloadType(string $payloadContent, &$matches = []): int 531 | { 532 | // Body. 533 | if (0 !== preg_match('/^Body/', $payloadContent, $matches)) { 534 | return self::PAYLOAD_BODY; 535 | } 536 | 537 | // Headers. 538 | if (0 !== preg_match('/^Headers/', $payloadContent, $matches)) { 539 | return self::PAYLOAD_HEADERS; 540 | } 541 | 542 | // Attributes. 543 | if (0 !== preg_match('/^Attributes(?:\h+\((?[^\)]+)\))?/', $payloadContent, $matches)) { 544 | return self::PAYLOAD_ATTRIBUTES; 545 | } 546 | 547 | // Schema. 548 | if (0 !== preg_match('/^Schema/', $payloadContent, $matches)) { 549 | return self::PAYLOAD_SCHEMA; 550 | } 551 | 552 | // Unknown. 553 | return self::PAYLOAD_UNKNOWN; 554 | } 555 | 556 | protected function getMarkdownParser() 557 | { 558 | if (null === static::$_markdownParser) { 559 | static::$_markdownParser = new CommonMark\DocParser( 560 | CommonMark\Environment::createCommonMarkEnvironment() 561 | ); 562 | } 563 | 564 | return static::$_markdownParser; 565 | } 566 | 567 | private function debug(string $message) 568 | { 569 | //echo $message; 570 | } 571 | } 572 | -------------------------------------------------------------------------------- /test/integration/Parser.php: -------------------------------------------------------------------------------- 1 | given( 20 | $parser = new SUT(), 21 | $datum = '' 22 | ) 23 | ->when($result = $parser->parse($datum)) 24 | ->then 25 | ->object($result) 26 | ->isEqualTo(new IR\Document()); 27 | } 28 | 29 | public function test_metadata() 30 | { 31 | $this 32 | ->given( 33 | $parser = new SUT(), 34 | $datum = 'FORMAT: 1A' . "\n" . 'HOST: https://example.org/' 35 | ) 36 | ->when($result = $parser->parse($datum)) 37 | ->then 38 | ->let( 39 | $document = new IR\Document(), 40 | $document->metadata = [ 41 | 'format' => '1A', 42 | 'host' => 'https://example.org/' 43 | ] 44 | ) 45 | ->object($result) 46 | ->isEqualTo($document); 47 | } 48 | 49 | public function test_api_name_and_overview_section() 50 | { 51 | $this 52 | ->given( 53 | $parser = new SUT(), 54 | $datum = 55 | '# Basic _example_ API' . "\n" . 56 | 'Welcome to the **ACME Blog** API. This API provides access to the **ACME' . "\n" . 57 | 'Blog** service.' 58 | ) 59 | ->when($result = $parser->parse($datum)) 60 | ->then 61 | ->let( 62 | $document = new IR\Document(), 63 | $document->apiName = 'Basic _example_ API' 64 | ) 65 | ->object($result) 66 | ->isEqualTo($document); 67 | } 68 | 69 | public function test_one_empty_group() 70 | { 71 | $this 72 | ->given( 73 | $parser = new SUT(), 74 | $datum = '# Group Foo Bar' 75 | ) 76 | ->when($result = $parser->parse($datum)) 77 | ->then 78 | ->let( 79 | $group = new IR\Group(), 80 | $group->name = 'Foo Bar', 81 | 82 | $document = new IR\Document(), 83 | $document->groups[] = $group 84 | ) 85 | ->object($result) 86 | ->isEqualTo($document); 87 | } 88 | 89 | public function test_many_empty_groups() 90 | { 91 | $this 92 | ->given( 93 | $parser = new SUT(), 94 | $datum = '# Group Foo Bar' . "\n" . '# Group Baz Qux' 95 | ) 96 | ->when($result = $parser->parse($datum)) 97 | ->then 98 | ->let( 99 | $group1 = new IR\Group(), 100 | $group1->name = 'Foo Bar', 101 | 102 | $group2 = new IR\Group(), 103 | $group2->name = 'Baz Qux', 104 | 105 | $document = new IR\Document(), 106 | $document->groups[] = $group1, 107 | $document->groups[] = $group2 108 | ) 109 | ->object($result) 110 | ->isEqualTo($document); 111 | } 112 | 113 | public function test_one_empty_resource() 114 | { 115 | $this 116 | ->given( 117 | $parser = new SUT(), 118 | $datum = '# Foo Bar [/foo/bar]' 119 | ) 120 | ->when($result = $parser->parse($datum)) 121 | ->then 122 | ->let( 123 | $resource = new IR\Resource(), 124 | $resource->name = 'Foo Bar', 125 | $resource->uriTemplate = '/foo/bar', 126 | 127 | $document = new IR\Document(), 128 | $document->resources[] = $resource 129 | ) 130 | ->object($result) 131 | ->isEqualTo($document); 132 | } 133 | 134 | public function test_many_empty_resources() 135 | { 136 | $this 137 | ->given( 138 | $parser = new SUT(), 139 | $datum = '# Foo Bar [/foo/bar]' . "\n" . '# Baz Qux [/baz/qux]' 140 | ) 141 | ->when($result = $parser->parse($datum)) 142 | ->then 143 | ->let( 144 | $resource1 = new IR\Resource(), 145 | $resource1->name = 'Foo Bar', 146 | $resource1->uriTemplate = '/foo/bar', 147 | 148 | $resource2 = new IR\Resource(), 149 | $resource2->name = 'Baz Qux', 150 | $resource2->uriTemplate = '/baz/qux', 151 | 152 | $document = new IR\Document(), 153 | $document->resources[] = $resource1, 154 | $document->resources[] = $resource2 155 | ) 156 | ->object($result) 157 | ->isEqualTo($document); 158 | } 159 | 160 | public function test_one_empty_resource_within_a_group() 161 | { 162 | $this 163 | ->given( 164 | $parser = new SUT(), 165 | $datum = '# Group A' . "\n" . '## Foo Bar [/foo/bar]' 166 | ) 167 | ->when($result = $parser->parse($datum)) 168 | ->then 169 | ->let( 170 | $resource = new IR\Resource(), 171 | $resource->name = 'Foo Bar', 172 | $resource->uriTemplate = '/foo/bar', 173 | 174 | $group = new IR\Group(), 175 | $group->name = 'A', 176 | $group->resources[] = $resource, 177 | 178 | $document = new IR\Document(), 179 | $document->groups[] = $group 180 | ) 181 | ->object($result) 182 | ->isEqualTo($document); 183 | } 184 | 185 | public function test_many_empty_resources_within_a_group() 186 | { 187 | $this 188 | ->given( 189 | $parser = new SUT(), 190 | $datum = 191 | '# Group A' . "\n" . 192 | '## Foo Bar [/foo/bar]' . "\n" . 193 | '## Baz Qux [/baz/qux]' 194 | ) 195 | ->when($result = $parser->parse($datum)) 196 | ->then 197 | ->let( 198 | $resource1 = new IR\Resource(), 199 | $resource1->name = 'Foo Bar', 200 | $resource1->uriTemplate = '/foo/bar', 201 | 202 | $resource2 = new IR\Resource(), 203 | $resource2->name = 'Baz Qux', 204 | $resource2->uriTemplate = '/baz/qux', 205 | 206 | $group = new IR\Group(), 207 | $group->name = 'A', 208 | $group->resources[] = $resource1, 209 | $group->resources[] = $resource2, 210 | 211 | $document = new IR\Document(), 212 | $document->groups[] = $group 213 | ) 214 | ->object($result) 215 | ->isEqualTo($document); 216 | } 217 | 218 | public function test_empty_resources_and_groups_at_different_levels() 219 | { 220 | $this 221 | ->given( 222 | $parser = new SUT(), 223 | $datum = 224 | '# Resource 1 [/resource/1]' . "\n" . 225 | '# Group A' . "\n" . 226 | '## Resource 2 [/group/a/resource/2]' . "\n" . 227 | '## Resource 3 [/group/a/resource/3]' . "\n" . 228 | '# Group B' . "\n" . 229 | '## Resource 4 [/group/b/resource/4]' . "\n" . 230 | '# Group C' . "\n" . 231 | '# Group D' . "\n" . 232 | '## Resource 5 [/group/d/resource/5]' . "\n" . 233 | '# Resource 6 [/resource/6]' . "\n" . 234 | '# Group E' . "\n" . 235 | '# Resource 7 [/resource/7]' 236 | ) 237 | ->when($result = $parser->parse($datum)) 238 | ->then 239 | ->let( 240 | $resource1 = new IR\Resource(), 241 | $resource1->name = 'Resource 1', 242 | $resource1->uriTemplate = '/resource/1', 243 | 244 | $resource2 = new IR\Resource(), 245 | $resource2->name = 'Resource 2', 246 | $resource2->uriTemplate = '/group/a/resource/2', 247 | 248 | $resource3 = new IR\Resource(), 249 | $resource3->name = 'Resource 3', 250 | $resource3->uriTemplate = '/group/a/resource/3', 251 | 252 | $resource4 = new IR\Resource(), 253 | $resource4->name = 'Resource 4', 254 | $resource4->uriTemplate = '/group/b/resource/4', 255 | 256 | $resource5 = new IR\Resource(), 257 | $resource5->name = 'Resource 5', 258 | $resource5->uriTemplate = '/group/d/resource/5', 259 | 260 | $resource6 = new IR\Resource(), 261 | $resource6->name = 'Resource 6', 262 | $resource6->uriTemplate = '/resource/6', 263 | 264 | $resource7 = new IR\Resource(), 265 | $resource7->name = 'Resource 7', 266 | $resource7->uriTemplate = '/resource/7', 267 | 268 | $groupA = new IR\Group(), 269 | $groupA->name = 'A', 270 | $groupA->resources[] = $resource2, 271 | $groupA->resources[] = $resource3, 272 | 273 | $groupB = new IR\Group(), 274 | $groupB->name = 'B', 275 | $groupB->resources[] = $resource4, 276 | 277 | $groupC = new IR\Group(), 278 | $groupC->name = 'C', 279 | 280 | $groupD = new IR\Group(), 281 | $groupD->name = 'D', 282 | $groupD->resources[] = $resource5, 283 | 284 | $groupE = new IR\Group(), 285 | $groupE->name = 'E', 286 | 287 | $document = new IR\Document(), 288 | $document->resources[] = $resource1, 289 | $document->resources[] = $resource6, 290 | $document->resources[] = $resource7, 291 | $document->groups[] = $groupA, 292 | $document->groups[] = $groupB, 293 | $document->groups[] = $groupC, 294 | $document->groups[] = $groupD, 295 | $document->groups[] = $groupE 296 | ) 297 | ->object($result) 298 | ->isEqualTo($document); 299 | } 300 | 301 | public function test_empty_resource_at_level_2_without_a_parent_group() 302 | { 303 | $this 304 | ->given( 305 | $parser = new SUT(), 306 | $datum = '## Resource 1 [/resource/1]' 307 | ) 308 | ->when($result = $parser->parse($datum)) 309 | ->then 310 | ->object($result) 311 | ->isEqualTo(new IR\Document()); 312 | } 313 | 314 | public function test_one_empty_action_in_a_resource_in_a_group() 315 | { 316 | $this 317 | ->given( 318 | $parser = new SUT(), 319 | $datum = 320 | '# Group A' . "\n" . 321 | '## Resource 1 [/group/a/resource/1]' . "\n" . 322 | '### Action Foo Bar [GET]' 323 | ) 324 | ->when($result = $parser->parse($datum)) 325 | ->then 326 | ->let( 327 | $action = new IR\Action(), 328 | $action->name = 'Action Foo Bar', 329 | $action->requestMethod = 'GET', 330 | 331 | $resource = new IR\Resource(), 332 | $resource->name = 'Resource 1', 333 | $resource->uriTemplate = '/group/a/resource/1', 334 | $resource->actions[] = $action, 335 | 336 | $group = new IR\Group(), 337 | $group->name = 'A', 338 | $group->resources[] = $resource, 339 | 340 | $document = new IR\Document(), 341 | $document->groups[] = $group 342 | ) 343 | ->object($result) 344 | ->isEqualTo($document); 345 | } 346 | 347 | public function test_one_empty_action_in_a_resource() 348 | { 349 | $this 350 | ->given( 351 | $parser = new SUT(), 352 | $datum = 353 | '# Resource 1 [/group/a/resource/1]' . "\n" . 354 | '## Action Foo Bar [GET]' 355 | ) 356 | ->when($result = $parser->parse($datum)) 357 | ->then 358 | ->let( 359 | $action = new IR\Action(), 360 | $action->name = 'Action Foo Bar', 361 | $action->requestMethod = 'GET', 362 | 363 | $resource = new IR\Resource(), 364 | $resource->name = 'Resource 1', 365 | $resource->uriTemplate = '/group/a/resource/1', 366 | $resource->actions[] = $action, 367 | 368 | $document = new IR\Document(), 369 | $document->resources[] = $resource 370 | ) 371 | ->object($result) 372 | ->isEqualTo($document); 373 | } 374 | 375 | public function test_many_empty_actions_in_a_resource_in_a_group() 376 | { 377 | $this 378 | ->given( 379 | $parser = new SUT(), 380 | $datum = 381 | '# Group A' . "\n" . 382 | '## Resource 1 [/group/a/resource/1]' . "\n" . 383 | '### Action Foo Bar [GET /group/a/resource/1/action/foo-bar]' . "\n" . 384 | '### Action Baz Qux [HELLO]' . "\n" . 385 | '### GET' 386 | ) 387 | ->when($result = $parser->parse($datum)) 388 | ->then 389 | ->let( 390 | $action1 = new IR\Action(), 391 | $action1->name = 'Action Foo Bar', 392 | $action1->requestMethod = 'GET', 393 | $action1->uriTemplate = '/group/a/resource/1/action/foo-bar', 394 | 395 | $action2 = new IR\Action(), 396 | $action2->name = 'Action Baz Qux', 397 | $action2->requestMethod = 'HELLO', 398 | 399 | $action3 = new IR\Action(), 400 | $action3->requestMethod = 'GET', 401 | 402 | $resource = new IR\Resource(), 403 | $resource->name = 'Resource 1', 404 | $resource->uriTemplate = '/group/a/resource/1', 405 | $resource->actions[] = $action1, 406 | $resource->actions[] = $action2, 407 | $resource->actions[] = $action3, 408 | 409 | $group = new IR\Group(), 410 | $group->name = 'A', 411 | $group->resources[] = $resource, 412 | 413 | $document = new IR\Document(), 414 | $document->groups[] = $group 415 | ) 416 | ->object($result) 417 | ->isEqualTo($document); 418 | } 419 | 420 | public function test_one_action_with_no_payloads_in_a_resource() 421 | { 422 | $this 423 | ->given( 424 | $parser = new SUT(), 425 | $datum = 426 | '# Resource 1 [/group/a/resource/1]' . "\n" . 427 | '## Action Foo Bar [GET]' . "\n" . 428 | '+ Request A (media/type1)' . "\n" . 429 | '+ Response 123 (media/type2)' . "\n" . 430 | '+ Request B' . "\n" . 431 | '+ Request' . "\n" . 432 | '+ Response (media/type3)' . "\n" . 433 | '+ Request (media/type4)' . "\n" . 434 | '+ Response' 435 | ) 436 | ->when($result = $parser->parse($datum)) 437 | ->then 438 | ->let( 439 | $request1 = new IR\Request(), 440 | $request1->name = 'A', 441 | $request1->mediaType = 'media/type1', 442 | 443 | $request2 = new IR\Request(), 444 | $request2->name = 'B', 445 | 446 | $request3 = new IR\Request(), 447 | 448 | $request4 = new IR\Request(), 449 | $request4->mediaType = 'media/type4', 450 | 451 | $response1 = new IR\Response(), 452 | $response1->statusCode = 123, 453 | $response1->mediaType = 'media/type2', 454 | 455 | $response2 = new IR\Response(), 456 | $response2->mediaType = 'media/type3', 457 | 458 | $response3 = new IR\Response(), 459 | 460 | $action = new IR\Action(), 461 | $action->name = 'Action Foo Bar', 462 | $action->requestMethod = 'GET', 463 | $action->messages[] = $request1, 464 | $action->messages[] = $response1, 465 | $action->messages[] = $request2, 466 | $action->messages[] = $request3, 467 | $action->messages[] = $response2, 468 | $action->messages[] = $request4, 469 | $action->messages[] = $response3, 470 | 471 | $resource = new IR\Resource(), 472 | $resource->name = 'Resource 1', 473 | $resource->uriTemplate = '/group/a/resource/1', 474 | $resource->actions[] = $action, 475 | 476 | $document = new IR\Document(), 477 | $document->resources[] = $resource 478 | ) 479 | ->object($result) 480 | ->isEqualTo($document); 481 | } 482 | 483 | public function test_one_action_with_bodies_as_payloads_in_a_resource() 484 | { 485 | $this 486 | ->given( 487 | $parser = new SUT(), 488 | $datum = 489 | '# Resource 1 [/group/a/resource/1]' . "\n" . 490 | '## Action Foo Bar [GET]' . "\n" . 491 | '+ Request A (media/type1)' . "\n\n" . 492 | 493 | ' body1 part1' . "\n" . 494 | ' body1 part2' . "\n\n" . 495 | 496 | '+ Response 123 (media/type2)' . "\n\n" . 497 | 498 | ' body2' 499 | ) 500 | ->when($result = $parser->parse($datum)) 501 | ->then 502 | ->let( 503 | $payload1 = new IR\Payload(), 504 | $payload1->body = 'body1 part1' . "\n" . 'body1 part2', 505 | 506 | $payload2 = new IR\Payload(), 507 | $payload2->body = 'body2', 508 | 509 | $request = new IR\Request(), 510 | $request->name = 'A', 511 | $request->mediaType = 'media/type1', 512 | $request->payload = $payload1, 513 | 514 | $response = new IR\Response(), 515 | $response->statusCode = 123, 516 | $response->mediaType = 'media/type2', 517 | $response->payload = $payload2, 518 | 519 | $action = new IR\Action(), 520 | $action->name = 'Action Foo Bar', 521 | $action->requestMethod = 'GET', 522 | $action->messages[] = $request, 523 | $action->messages[] = $response, 524 | 525 | $resource = new IR\Resource(), 526 | $resource->name = 'Resource 1', 527 | $resource->uriTemplate = '/group/a/resource/1', 528 | $resource->actions[] = $action, 529 | 530 | $document = new IR\Document(), 531 | $document->resources[] = $resource 532 | ) 533 | ->object($result) 534 | ->isEqualTo($document); 535 | } 536 | 537 | public function test_one_action_with_payloads_in_a_resource() 538 | { 539 | $this 540 | ->given( 541 | $parser = new SUT(), 542 | $datum = 543 | '# Resource 1 [/group/a/resource/1]' . "\n" . 544 | '## Action Foo Bar [GET]' . "\n" . 545 | '+ Request A (media/type1)' . "\n\n" . 546 | 547 | ' + Body' . "\n\n" . 548 | 549 | ' {"message": ' . "\n" . 550 | ' "Hello, World!"}' . "\n\n" . 551 | 552 | ' + Headers' . "\n\n" . 553 | 554 | ' Foo: Bar' . "\n" . 555 | ' Baz: Qux' . "\n\n" . 556 | 557 | ' + Schema' . "\n\n" . 558 | 559 | ' {' . "\n" . 560 | ' "$schema": "http://json-schema.org/draft-04/schema#",' . "\n" . 561 | ' "type": "object",' . "\n" . 562 | ' "properties": {' . "\n" . 563 | ' "message": {' . "\n" . 564 | ' "type": "string"' . "\n" . 565 | ' }' . "\n" . 566 | ' }' . "\n" . 567 | ' }' . "\n\n" . 568 | 569 | '+ Response 123 (media/type2)' . "\n\n" . 570 | 571 | ' + Body' . "\n\n" . 572 | 573 | ' {"message": "ok"}' . "\n\n" . 574 | 575 | ' + Headers' . "\n\n" . 576 | 577 | ' ooF: raB' . "\n" . 578 | ' zaB: xuQ' 579 | ) 580 | ->when($result = $parser->parse($datum)) 581 | ->then 582 | ->let( 583 | $payload1 = new IR\Payload(), 584 | $payload1->body = '{"message": ' . "\n" . '"Hello, World!"}', 585 | $payload1->headers = [ 586 | 'foo' => 'Bar', 587 | 'baz' => 'Qux' 588 | ], 589 | $payload1->schema = 590 | '{' . "\n" . 591 | '"$schema": "http://json-schema.org/draft-04/schema#",' . "\n" . 592 | '"type": "object",' . "\n" . 593 | '"properties": {' . "\n" . 594 | '"message": {' . "\n" . 595 | '"type": "string"' . "\n" . 596 | '}' . "\n" . 597 | '}' . "\n" . 598 | '}', 599 | 600 | $payload2 = new IR\Payload(), 601 | $payload2->body = '{"message": "ok"}', 602 | $payload2->headers = [ 603 | 'oof' => 'raB', 604 | 'zab' => 'xuQ' 605 | ], 606 | 607 | $request = new IR\Request(), 608 | $request->name = 'A', 609 | $request->mediaType = 'media/type1', 610 | $request->payload = $payload1, 611 | 612 | $response = new IR\Response(), 613 | $response->statusCode = 123, 614 | $response->mediaType = 'media/type2', 615 | $response->payload = $payload2, 616 | 617 | $action = new IR\Action(), 618 | $action->name = 'Action Foo Bar', 619 | $action->requestMethod = 'GET', 620 | $action->messages[] = $request, 621 | $action->messages[] = $response, 622 | 623 | $resource = new IR\Resource(), 624 | $resource->name = 'Resource 1', 625 | $resource->uriTemplate = '/group/a/resource/1', 626 | $resource->actions[] = $action, 627 | 628 | $document = new IR\Document(), 629 | $document->resources[] = $resource 630 | ) 631 | ->object($result) 632 | ->isEqualTo($document); 633 | } 634 | 635 | public function test_one_action_with_fenced_codeblock_with_payloads_in_a_resource() 636 | { 637 | $this 638 | ->given( 639 | $parser = new SUT(), 640 | $datum = 641 | '# Resource 1 [/group/a/resource/1]' . "\n" . 642 | '## Action Foo Bar [GET]' . "\n" . 643 | '+ Request A (media/type1)' . "\n\n" . 644 | 645 | ' + Body' . "\n\n" . 646 | 647 | ' ```' . "\n" . 648 | ' {"message": ' . "\n" . 649 | ' "Hello, World!"}' . "\n" . 650 | ' ```' . "\n\n" . 651 | 652 | ' + Headers' . "\n\n" . 653 | 654 | ' Foo: Bar' . "\n" . 655 | ' Bar: Qux' . "\n\n" . 656 | 657 | ' + Schema' . "\n\n" . 658 | 659 | ' ```' . "\n" . 660 | ' {' . "\n" . 661 | ' "$schema": "http://json-schema.org/draft-04/schema#",' . "\n" . 662 | ' "type": "object",' . "\n" . 663 | ' "properties": {' . "\n" . 664 | ' "message": {' . "\n" . 665 | ' "type": "string"' . "\n" . 666 | ' }' . "\n" . 667 | ' }' . "\n" . 668 | ' }' . "\n" . 669 | ' ```' 670 | ) 671 | ->when($result = $parser->parse($datum)) 672 | ->then 673 | ->let( 674 | $payload = new IR\Payload(), 675 | $payload->body = '{"message": ' . "\n" . ' "Hello, World!"}' . "\n", 676 | $payload->headers = [ 677 | 'foo' => 'Bar', 678 | 'bar' => 'Qux' 679 | ], 680 | $payload->schema = 681 | '{' . "\n" . 682 | ' "$schema": "http://json-schema.org/draft-04/schema#",' . "\n" . 683 | ' "type": "object",' . "\n" . 684 | ' "properties": {' . "\n" . 685 | ' "message": {' . "\n" . 686 | ' "type": "string"' . "\n" . 687 | ' }' . "\n" . 688 | ' }' . "\n" . 689 | '}' . "\n", 690 | 691 | $request = new IR\Request(), 692 | $request->name = 'A', 693 | $request->mediaType = 'media/type1', 694 | $request->payload = $payload, 695 | 696 | $action = new IR\Action(), 697 | $action->name = 'Action Foo Bar', 698 | $action->requestMethod = 'GET', 699 | $action->messages[] = $request, 700 | 701 | $resource = new IR\Resource(), 702 | $resource->name = 'Resource 1', 703 | $resource->uriTemplate = '/group/a/resource/1', 704 | $resource->actions[] = $action, 705 | 706 | $document = new IR\Document(), 707 | $document->resources[] = $resource 708 | ) 709 | ->object($result) 710 | ->isEqualTo($document); 711 | } 712 | 713 | public function test_one_action_with_a_description_and_payloads_in_a_resource() 714 | { 715 | $this 716 | ->given( 717 | $parser = new SUT(), 718 | $datum = 719 | '# Resource 1 [/group/a/resource/1]' . "\n" . 720 | '## Action Foo Bar [GET]' . "\n" . 721 | '+ Request A (media/type1)' . "\n\n" . 722 | 723 | ' This is a description.' . "\n\n" . 724 | 725 | ' + Body' . "\n\n" . 726 | 727 | ' body1' 728 | ) 729 | ->when($result = $parser->parse($datum)) 730 | ->then 731 | ->let( 732 | $payload = new IR\Payload(), 733 | $payload->body = 'This is a description.', 734 | 735 | $request = new IR\Request(), 736 | $request->name = 'A', 737 | $request->mediaType = 'media/type1', 738 | $request->payload = $payload, 739 | 740 | $action = new IR\Action(), 741 | $action->name = 'Action Foo Bar', 742 | $action->requestMethod = 'GET', 743 | $action->messages[] = $request, 744 | 745 | $resource = new IR\Resource(), 746 | $resource->name = 'Resource 1', 747 | $resource->uriTemplate = '/group/a/resource/1', 748 | $resource->actions[] = $action, 749 | 750 | $document = new IR\Document(), 751 | $document->resources[] = $resource 752 | ) 753 | ->object($result) 754 | ->isEqualTo($document); 755 | } 756 | } 757 | --------------------------------------------------------------------------------