├── 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 |
3 |
4 |
5 | # atoum/apiblueprint-extension [](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 |
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 | '' . $tag . '>' .
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 |
221 |
222 |
223 |
224 | HTTP API
225 |
226 |
227 |
228 |
229 |
230 |
231 |
234 |
235 |
242 |
243 |
244 |
245 |
293 |
294 |
295 |
--------------------------------------------------------------------------------
/res/overview.svg:
--------------------------------------------------------------------------------
1 |
2 | example.apib 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] My_awesome_API.php atoum runner atoum extension compiles test verdict finds all .apib files 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 |
--------------------------------------------------------------------------------