Skip to content

Commit 17ebd92

Browse files
committed
Version 1.0.0
1 parent 36891fd commit 17ebd92

33 files changed

+2093
-0
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/vendor/
2+
/.idea/
3+
.phpunit.result.cache
4+
/composer.lock

.idea/.gitignore

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# About this project
2+
3+
This is pretty raw library which is aimed to provide full HTTP client mock for `stripe-php` library in order to
4+
perform tests without actually sending HTTP requests to Stripe.
5+
6+
At the moment this library covers a small part of `stripe-php` functionality but there is a hope that one day
7+
it will be developed enough to cover everything `stripe-php` provides.
8+
9+
The main idea behind this library is to provide stateful "server" which remembers of what he was asked before.
10+
The state is not saved between lifecycles but within a single lifecycle the state is preserved. This is the
11+
main difference from [the official Stripe's server mock](https://github.com/stripe/stripe-mock).
12+
Another reason for this library to exist is to avoid having a separate component of you application written
13+
in a different programming language and thus requiring to have another container with it (or environment
14+
configuration in container-less case).
15+
16+
# Taking a part in this project
17+
18+
The task "to cover all the functionality of `stripe-php` library" is really huge. It's possible to achieve it,
19+
but it requires a huge efforts (as huge as the task is) and is financially senseless in order to be implemented
20+
by a group of programmers while working on the main project.
21+
22+
Therefore, it is **highly** appreciated to take part in this project for anyone interested in any of thees ways:
23+
- You have an idea on how to improve this project, or you see that something is wrong? Don't hesitate opening
24+
an issue.
25+
- You have time and will to add/improve functionality or fix a bug? Your pull requests would be **extremely**
26+
valuable
27+
28+
# Installation
29+
30+
Nothing special here, just use composer to install the package and that's it.
31+
32+
# Usage
33+
34+
Usage is as simple as two lines of code in bootstrap script of your PHPUnit configuration:
35+
36+
>Readdle\StripeHttpClientMock\HttpClient::$apiKey = "your_api_key_goes_here";
37+
>
38+
>Stripe\ApiRequestor\ApiRequestor::setHttpClient(new Readdle\StripeHttpClientMock\HttpClient());
39+
40+
That's it, now you have your instance of `stripe-php`'s HTTP client mocked, and it will "communicate" with a
41+
piece of code instead of performing real HTTP requests.
42+
43+
# Overall structure
44+
45+
### Files
46+
47+
`Collection.php` - representation of collection of entities
48+
49+
`EntityManager.php` - manager, which is responsible for creating/updating/deleting/listing entities, also performs
50+
search and paging functions
51+
52+
`HttpClient.php` - it's obvious from its name, HTTP client which substitutes `stripe-php`'s curl-based client
53+
54+
### Directories
55+
56+
`Entity` - implemented entities, each entity **must** extend AbstractEntity class
57+
58+
`Error` - erroneous responses, each error **must** extend AbstractError class
59+
60+
`Success` - success responses, which doesn't contain entity in it, but an information about an action
61+
and its result, **must** implement `ResponseInterface`
62+
63+
# Entity structure
64+
65+
### Props
66+
67+
Each entity **must** have at least `$props` property filled in with all the fields this entity has (the list of fields
68+
for each entity could be found in [Stripe's documentation](https://stripe.com/docs/api)).
69+
70+
### Prefix
71+
72+
In most cases an entity will override method `prefix()` in order to have the correct prefix in newly generated IDs.
73+
74+
---
75+
76+
The following is optional and should be applied in cases when it is needed, however to have **all** the functionality
77+
covered it's better to follow these instructions where it's applicable.
78+
79+
---
80+
81+
### Creation of an entity
82+
83+
If the creation of an entity requires any additional actions, you can override `create()` method and do something
84+
prior/after the creation happens. Usually it's useful for creating related entities.
85+
86+
### Expandable properties
87+
88+
Entity may have property `$expandableProps` filled with the fields which could be expanded (they are marked
89+
as "expandable" in the documentation). By default, if a field is listed in this array and request contains
90+
`expand` parameter which requires this field to be expanded, an entity will try to expand this field automatically.
91+
The field name will be used as sub-entity name (class) and its value will be used as an ID. This is a recursive
92+
action, so in case when `expand` parameter requires expanding of a field in the expanded field, it will be passed
93+
to subsequent entity and will be expanded in the same way.
94+
95+
In case when the logic of expanding the field is more complex, you can override `howToExpand()` method and
96+
implement specific piece of logic for the entity there. Please, don't forget to call parent method for cases
97+
when your implementation is not covering the case.
98+
99+
### Sub-actions
100+
101+
Entity may have property `$subActions` filled with all the available sub-actions. Sub-action is an action performed on
102+
an entity which is not following (due to impossibility) the REST concept. For example:
103+
> POST /v1/payment_methods/:id/attach
104+
>
105+
> POST /v1/payment_methods/:id/detach
106+
107+
These are not actions of creating/updating/deleting, but performing additional action on the exact entity.
108+
109+
`$subActions` is an associative array where the key is an action (what is stated in the request) and the value is
110+
a name of a method, which is responsible for performing this action. This method should return an implementation
111+
of `ResponseInterface` and it will be returned as an "API response" to the `stripe-php` client.
112+
113+
In case when this functionality can't fulfil an entity's behaviour, you can override `subAction()` method and implement
114+
custom logic for that entity. Don't forget to call parent method in order to cover regular cases.
115+
116+
### Sub-entities
117+
118+
Entity may have property `$subEntities` filled with entity names of its sub-entities. Sub-entity is an entity
119+
which is accessed through the main entity, take a look at these examples:
120+
> POST /v1/customers/:id/tax_ids
121+
>
122+
> GET /v1/customers/:id/tax_ids/:id
123+
>
124+
> DELETE /v1/customers/:id/tax_ids/:id
125+
>
126+
> GET /v1/customers/:id/tax_ids
127+
>
128+
Despite the fact that these requests are performed (following the REST concept) on the `customer` entity, actually
129+
all of them are performed on the `tax_id` entity of the customer with the specified ID. So, the library transforms
130+
all these actions in a way like they are performed (in this particular example) on the `tax_id` entity, but using
131+
`customer`'s ID as a filter (or a value for the appropriate field in case when the `tax_id` entity is being created).
132+
133+
### Uncovered requests
134+
135+
It is possible (and even likely) that there are some specific requests which are not covered by this library.
136+
If the requested URL differs from a regular one (performing an action on an entity, performing a sub-action on an
137+
entity or performing an action on sub-entity), you can override `parseUrlTail()` method of the appropriate entity and
138+
add logic which parses this specific request. But it's most likely that the library won't be able to deal with
139+
this result, so there are two preferred ways of sorting it out:
140+
- open an issue and ask for the functionality that the library lacks with detailed description of how to implement it
141+
- improve the library and create a pull request with code which covers this specific case
142+
143+
Both options would be **highly** appreciated and the reaction will follow as soon as it possible.

composer.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "readdle/stripe-httpclient-mock",
3+
"description": "Mock of Stripe's HttpClient which can be used in testing purposes in order to test your code and not to perform actual HTTP requests",
4+
"type": "library",
5+
"license": "MIT",
6+
"version": "1.0.0",
7+
"php": ">=7.4",
8+
"autoload": {
9+
"psr-4": {
10+
"Readdle\\StripeHttpClientMock\\": "src/"
11+
}
12+
},
13+
"require": {
14+
"ext-json": "*",
15+
"stripe/stripe-php": "^7.56"
16+
},
17+
"require-dev": {
18+
"phpunit/phpunit": "^9"
19+
}
20+
}

src/Collection.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Readdle\StripeHttpClientMock;
5+
6+
use Readdle\StripeHttpClientMock\Entity\AbstractEntity;
7+
8+
class Collection
9+
{
10+
public string $object = 'list';
11+
public array $data = [];
12+
public bool $hasMore = false;
13+
public string $url = '';
14+
15+
public function __construct(array $data = [], bool $hasMore = false, string $url = '')
16+
{
17+
$this->data = $data;
18+
$this->hasMore = $hasMore;
19+
$this->url = $url;
20+
}
21+
22+
public function add(AbstractEntity $entity)
23+
{
24+
$this->data[] = $entity;
25+
}
26+
27+
public function toArray(): array
28+
{
29+
return [
30+
'object' => $this->object,
31+
'data' => array_map(fn($entity) => $entity->toArray(), $this->data),
32+
'has_more' => $this->hasMore,
33+
'url' => $this->url,
34+
];
35+
}
36+
37+
public function toString(): string
38+
{
39+
return json_encode($this->toArray());
40+
}
41+
}

src/Entity/AbstractEntity.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Readdle\StripeHttpClientMock\Entity;
5+
6+
use Exception;
7+
use Readdle\StripeHttpClientMock\Collection;
8+
use Readdle\StripeHttpClientMock\ResponseInterface;
9+
10+
class AbstractEntity implements ResponseInterface
11+
{
12+
protected array $props = [];
13+
protected static array $expandableProps = [];
14+
protected static array $subActions = [];
15+
protected static array $subEntities = [];
16+
17+
public function __get(string $key)
18+
{
19+
return array_key_exists($key, $this->props) ? $this->props[$key] : null;
20+
}
21+
22+
public function __set(string $key, $value): void
23+
{
24+
if (!array_key_exists($key, $this->props)) {
25+
return;
26+
}
27+
28+
$this->props[$key] = $value;
29+
}
30+
31+
public static function create(string $id, array $props = []): ResponseInterface
32+
{
33+
$entity = new static();
34+
35+
$class = get_class($entity);
36+
$shortClass = substr($class, strrpos($class, '\\') + 1);
37+
$object = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $shortClass));
38+
39+
$entity->props['id'] = $id;
40+
$entity->props['object'] = $object;
41+
42+
$entity->update($props);
43+
44+
return $entity;
45+
}
46+
47+
public function update(array $props): void
48+
{
49+
foreach ($props as $key => $value) {
50+
if (is_bool($this->$key) && is_string($value)) {
51+
$value = $value !== 'false';
52+
}
53+
54+
$this->$key = $value;
55+
}
56+
}
57+
58+
/**
59+
* @throws Exception
60+
* @noinspection PhpUnused
61+
*/
62+
public function subAction(string $action, array $params): ResponseInterface
63+
{
64+
if (!array_key_exists($action, static::$subActions)) {
65+
throw new Exception();
66+
}
67+
68+
return call_user_func([$this, static::$subActions[$action]], $params);
69+
}
70+
71+
public static function parseUrlTail(string $tail): array
72+
{
73+
if (!preg_match('/^(?P<entityId>\w+)(?P<subAction>\/[a-z_]+)?(?P<subEntityId>\/\w+)?$/', $tail, $matches)) {
74+
return [];
75+
}
76+
77+
$result = [
78+
'entityId' => $matches['entityId'],
79+
];
80+
81+
if (!array_key_exists('subAction', $matches)) {
82+
return $result;
83+
}
84+
85+
$result['subAction'] = ltrim($matches['subAction'], '/');
86+
87+
if (!in_array($result['subAction'], static::$subEntities)) {
88+
return $result;
89+
}
90+
91+
$result['subEntity'] = $result['subAction'];
92+
unset($result['subAction']);
93+
94+
$result['subEntityId'] = array_key_exists('subEntityId', $matches)
95+
? ltrim($matches['subEntityId'], '/')
96+
: null;
97+
98+
return $result;
99+
}
100+
101+
public static function prefix(): string
102+
{
103+
return strtolower(substr(static::class, strrpos(static::class, '\\') + 1));
104+
}
105+
106+
public static function howToExpand(string $propertyName): ?array
107+
{
108+
if (in_array($propertyName, static::$expandableProps)) {
109+
return [
110+
'target' => 'expandableProp',
111+
'object' => $propertyName,
112+
];
113+
}
114+
115+
return null;
116+
}
117+
118+
public function toArray(): array
119+
{
120+
return $this->toArrayRecursive($this->props);
121+
}
122+
123+
private function toArrayRecursive(array $inArray): array
124+
{
125+
$outArray = [];
126+
127+
foreach ($inArray as $key => $value) {
128+
if ($value instanceof AbstractEntity || $value instanceof Collection) {
129+
$outArray[$key] = $value->toArray();
130+
} elseif (is_array($value)) {
131+
$outArray[$key] = $this->toArrayRecursive($value);
132+
} else {
133+
$outArray[$key] = $value;
134+
}
135+
}
136+
137+
return $outArray;
138+
}
139+
140+
public function toString(): string
141+
{
142+
return json_encode($this->toArray());
143+
}
144+
}

src/Entity/Coupon.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Readdle\StripeHttpClientMock\Entity;
5+
6+
class Coupon extends AbstractEntity
7+
{
8+
protected array $props = [
9+
'amount_off' => null,
10+
'created' => null,
11+
'currency' => null,
12+
'duration' => null,
13+
'duration_in_months' => null,
14+
'livemode' => false,
15+
'max_redemptions' => null,
16+
'metadata' => [],
17+
'name' => null,
18+
'percent_off' => null,
19+
'redeem_by' => null,
20+
'times_redeemed' => 0,
21+
'valid' => true,
22+
];
23+
24+
public static function prefix(): string
25+
{
26+
return '';
27+
}
28+
}

0 commit comments

Comments
 (0)