This commit is contained in:
ErickSkrauch 2019-04-01 16:04:08 +02:00
commit 7211fbc190
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
30 changed files with 4562 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/vendor/
/build/
.phpunit.result.cache
.php_cs.cache

5
.php_cs.dist Normal file
View File

@ -0,0 +1,5 @@
<?php
$finder = \PhpCsFixer\Finder::create()
->in(__DIR__);
return \Ely\CS\Config::create()
->setFinder($finder);

34
.travis.yml Normal file
View File

@ -0,0 +1,34 @@
language: php
php:
- 7.1
- 7.2
- 7.3
cache:
directories:
- vendor
- $HOME/.composer
env:
global:
- DEFAULT_COMPOSER_FLAGS="--optimize-autoloader --no-progress"
- COMPOSER_NO_INTERACTION=1
before_script:
- composer global show hirak/prestissimo -q || travis_retry composer global require $DEFAULT_COMPOSER_FLAGS hirak/prestissimo
- travis_retry composer install
- travis_retry phpenv rehash
stages:
- Static Code Analysis
- Test
jobs:
include:
- stage: Static Code Analysis
php: 7.3
script:
- vendor/bin/php-cs-fixer fix -v --dry-run
script:
- vendor/bin/phpunit

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019 Ely.by <team@ely.by>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

62
README.md Normal file
View File

@ -0,0 +1,62 @@
# Mojang API
This package provides easy access to the Minecraft related API of Mojang.
The library is built on the top of the [Guzzle HTTP client](https://github.com/guzzle/guzzle),
has custom errors handler and automatic retry in case of problems with Mojang.
> Please note that this is not a complete implementation of all available APIs.
If you don't find the method you need, [open Issue](https://github.com/elyby/mojang-api/issues/new)
or [submit a PR](https://github.com/elyby/mojang-api/compare) with the implementation.
[![Latest Version on Packagist][ico-version]][link-packagist]
[![Total Downloads][ico-downloads]][link-downloads]
[![Software License][ico-license]](LICENSE.md)
[![Build Status][ico-build-status]][link-build-status]
## Installation
To install, use composer:
```bash
composer require ely/mojang-api
```
## Usage
To get the configured `Api` object right away, just use the static `create()` method:
```php
<?php
$api = \Ely\Mojang\Api::create();
$response = $api->usernameToUUID('erickskrauch');
echo $response->getId();
```
## Testing
```bash
$ ./vendor/bin/phpunit
```
## Contributing
Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
## Credits
This package was designed and developed within the [Ely.by](http://ely.by) project team. We also thank all the
[contributors](link-contributors) for their help.
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
[ico-version]: https://img.shields.io/packagist/v/ely/mojang-api.svg?style=flat-square
[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square
[ico-downloads]: https://img.shields.io/packagist/dt/ely/mojang-api.svg?style=flat-square
[ico-build-status]: https://img.shields.io/travis/elyby/mojang-api/master.svg?style=flat-square
[link-packagist]: https://packagist.org/packages/ely/mojang-api
[link-contributors]: ../../contributors
[link-downloads]: https://packagist.org/packages/ely/mojang-api/stats
[link-build-status]: https://travis-ci.org/elyby/mojang-api

35
composer.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "ely/mojang-api",
"description": "",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "ErickSkrauch",
"email": "erickskrauch@yandex.ru"
}
],
"require": {
"php": ">=7.1.0",
"ext-json": "*",
"guzzlehttp/guzzle": "^6.0.0",
"ramsey/uuid": "^3.0.0"
},
"require-dev": {
"ely/php-code-style": "^0.3.0",
"phpunit/phpunit": "^8.0.0"
},
"config": {
"sort-packages": true
},
"autoload": {
"psr-4": {
"Ely\\Mojang\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Ely\\Mojang\\Test\\": "tests"
}
}
}

2987
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
phpunit.xml Normal file
View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<logging>
<log
type="coverage-html"
target="./build/coverage/html"
/>
<log
type="coverage-clover"
target="./build/coverage/log/coverage.xml"
/>
</logging>
<testsuites>
<testsuite name="Package Test Suite">
<directory suffix=".php">./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">./</directory>
<exclude>
<directory suffix=".php">./vendor</directory>
<directory suffix=".php">./tests</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

265
src/Api.php Normal file
View File

@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang;
use Ely\Mojang\Middleware\ResponseConverterMiddleware;
use Ely\Mojang\Middleware\RetryMiddleware;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
use Ramsey\Uuid\Uuid;
class Api {
/**
* @var ClientInterface
*/
private $client;
public function __construct(ClientInterface $client) {
$this->client = $client;
}
/**
* @param callable $handler HTTP handler function to use with the stack. If no
* handler is provided, the best handler for your
* system will be utilized.
*
* @return static
*/
public static function create(callable $handler = null): self {
$stack = HandlerStack::create($handler);
// use after method because middleware executes in reverse order
$stack->after('http_errors', ResponseConverterMiddleware::create(), 'mojang_response_converter');
$stack->push(RetryMiddleware::create(), 'retry');
return new static(new GuzzleClient([
'handler' => $stack,
'timeout' => 10,
]));
}
/**
* @param string $username
* @param int $atTime
*
* @return \Ely\Mojang\Response\ProfileInfo
*
* @throws \Ely\Mojang\Exception\MojangApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url http://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time
*/
public function usernameToUUID(string $username, int $atTime = null): Response\ProfileInfo {
$query = [];
if ($atTime !== null) {
$query['atTime'] = $atTime;
}
$response = $this->getClient()->request('GET', "https://api.mojang.com/users/profiles/minecraft/{$username}", [
'query' => $query,
]);
$data = $this->decode($response->getBody()->getContents());
return Response\ProfileInfo::createFromResponse($data);
}
/**
* @param string $uuid
*
* @return \Ely\Mojang\Response\ProfileResponse
*
* @throws \Ely\Mojang\Exception\MojangApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url http://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
*/
public function uuidToTextures(string $uuid): Response\ProfileResponse {
$response = $this->getClient()->request('GET', "https://sessionserver.mojang.com/session/minecraft/profile/{$uuid}", [
'query' => [
'unsigned' => false,
],
]);
$body = $this->decode($response->getBody()->getContents());
return new Response\ProfileResponse($body['id'], $body['name'], $body['properties']);
}
/**
* Helper method to exchange username to the corresponding textures.
*
* @param string $username
*
* @return \Ely\Mojang\Response\ProfileResponse
*
* @throws GuzzleException
* @throws \Ely\Mojang\Exception\MojangApiException
*/
public function usernameToTextures(string $username): Response\ProfileResponse {
return $this->uuidToTextures($this->usernameToUUID($username)->getId());
}
/**
* @param string $login
* @param string $password
* @param string $clientToken
*
* @return \Ely\Mojang\Response\AuthenticateResponse
*
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url https://wiki.vg/Authentication#Authenticate
*/
public function authenticate(
string $login,
string $password,
string $clientToken = null
): Response\AuthenticateResponse {
if ($clientToken === null) {
/** @noinspection PhpUnhandledExceptionInspection */
$clientToken = Uuid::uuid4()->toString();
}
$response = $this->getClient()->request('POST', 'https://authserver.mojang.com/authenticate', [
'json' => [
'username' => $login,
'password' => $password,
'clientToken' => $clientToken,
'requestUser' => true,
'agent' => [
'name' => 'Minecraft',
'version' => 1,
],
],
]);
$body = $this->decode($response->getBody()->getContents());
return new Response\AuthenticateResponse(
$body['accessToken'],
$body['clientToken'],
$body['availableProfiles'],
$body['selectedProfile'],
$body['user']
);
}
/**
* @param string $accessToken
*
* @return bool
*
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url https://wiki.vg/Authentication#Validate
*/
public function validate(string $accessToken): bool {
try {
$response = $this->getClient()->request('POST', 'https://authserver.mojang.com/authenticate', [
'json' => [
'accessToken' => $accessToken,
],
]);
if ($response->getStatusCode() === 204) {
return true;
}
} catch (Exception\ForbiddenException $e) {
// Suppress exception and let it just exit below
}
return false;
}
/**
* @param string $accessToken
* @param string $accountUuid
* @param \Psr\Http\Message\StreamInterface|resource|string $skinContents
* @param bool $isSlim
*
* @throws GuzzleException
*
* @url https://wiki.vg/Mojang_API#Upload_Skin
*/
public function uploadSkin(string $accessToken, string $accountUuid, $skinContents, bool $isSlim): void {
$this->getClient()->request('PUT', "https://api.mojang.com/user/profile/{$accountUuid}/skin", [
'multipart' => [
[
'name' => 'file',
'contents' => $skinContents,
'filename' => 'char.png',
],
[
'name' => 'model',
'contents' => $isSlim ? 'slim' : '',
],
],
'headers' => [
'Authorization' => 'Bearer ' . $accessToken,
],
]);
}
/**
* @param string $accessToken
* @param string $accountUuid
* @param string $serverId
*
* @throws GuzzleException
*
* @url https://wiki.vg/Protocol_Encryption#Client
*/
public function joinServer(string $accessToken, string $accountUuid, string $serverId): void {
$this->getClient()->request('POST', 'https://sessionserver.mojang.com/session/minecraft/join', [
'json' => [
'accessToken' => $accessToken,
'selectedProfile' => $accountUuid,
'serverId' => $serverId,
],
]);
}
/**
* @param string $username
* @param string $serverId
*
* @return \Ely\Mojang\Response\ProfileResponse
*
* @throws \Ely\Mojang\Exception\NoContentException
* @throws GuzzleException
*
* @url https://wiki.vg/Protocol_Encryption#Server
*/
public function hasJoinedServer(string $username, string $serverId): Response\ProfileResponse {
$uri = (new Uri('https://sessionserver.mojang.com/session/minecraft/hasJoined'))
->withQuery(http_build_query([
'username' => $username,
'serverId' => $serverId,
], '', '&', PHP_QUERY_RFC3986));
$request = new Request('GET', $uri);
$response = $this->getClient()->send($request);
$rawBody = $response->getBody()->getContents();
if (empty($rawBody)) {
throw new Exception\NoContentException($request, $response);
}
$body = $this->decode($rawBody);
return new Response\ProfileResponse($body['id'], $body['name'], $body['properties']);
}
/**
* @return ClientInterface
*/
protected function getClient(): ClientInterface {
return $this->client;
}
private function decode(string $response): array {
return json_decode($response, true);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use GuzzleHttp\Exception\ClientException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ForbiddenException extends ClientException implements MojangApiException {
public function __construct(RequestInterface $request, ResponseInterface $response) {
parent::__construct(
'The request was executed with a non-existent or expired access token',
$request,
$response
);
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use Throwable;
interface MojangApiException extends Throwable {
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class NoContentException extends RequestException implements MojangApiException {
public function __construct(RequestInterface $request, ResponseInterface $response) {
parent::__construct('No data were received in the response.', $request, $response);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use GuzzleHttp\Exception\ClientException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class TooManyRequestsException extends ClientException implements MojangApiException {
public function __construct(RequestInterface $request, ResponseInterface $response) {
parent::__construct(
'The request limit was exceeded. ' .
'Read the documentation for the method requested to find out which RPS is allowed.',
$request,
$response
);
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Middleware;
use Ely\Mojang\Exception;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ResponseConverterMiddleware {
/**
* @var callable
*/
private $nextHandler;
public function __construct(callable $nextHandler) {
$this->nextHandler = $nextHandler;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface {
$fn = $this->nextHandler;
/** @var PromiseInterface $promise */
$promise = $fn($request, $options);
return $promise->then(static function($response) use ($request) {
if ($response instanceof ResponseInterface) {
$method = $request->getMethod();
$statusCode = $response->getStatusCode();
if ($method === 'GET' && $statusCode === 204) {
throw new Exception\NoContentException($request, $response);
}
if ($statusCode === 403) {
throw new Exception\ForbiddenException($request, $response);
}
if ($statusCode === 429) {
throw new Exception\TooManyRequestsException($request, $response);
}
}
return $response;
});
}
public static function create(): callable {
return static function(callable $handler): callable {
return new static($handler);
};
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Middleware;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class RetryMiddleware {
public static function create(): callable {
return Middleware::retry([static::class, 'shouldRetry']);
}
public static function shouldRetry(
int $retries,
RequestInterface $request,
?ResponseInterface $response,
?GuzzleException $reason
): bool {
if ($retries >= 2) {
return false;
}
if ($reason instanceof ConnectException) {
return true;
}
if ($response !== null && (int)floor($response->getStatusCode() / 100) === 5) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
class AuthenticateResponse {
/**
* @var string
*/
private $accessToken;
/**
* @var string
*/
private $clientToken;
/**
* @var array
*/
private $rawAvailableProfiles;
/**
* @var array
*/
private $rawSelectedProfile;
/**
* @var array
*/
private $rawUser;
public function __construct(
string $accessToken,
string $clientToken,
array $availableProfiles,
array $selectedProfile,
array $user
) {
$this->accessToken = $accessToken;
$this->clientToken = $clientToken;
$this->rawAvailableProfiles = $availableProfiles;
$this->rawSelectedProfile = $selectedProfile;
$this->rawUser = $user;
}
public function getAccessToken(): string {
return $this->accessToken;
}
public function getClientToken(): string {
return $this->clientToken;
}
/**
* @return ProfileInfo[]
*/
public function getAvailableProfiles(): array {
return array_map([ProfileInfo::class, 'createFromResponse'], $this->rawAvailableProfiles);
}
public function getSelectedProfile(): ProfileInfo {
return ProfileInfo::createFromResponse($this->rawSelectedProfile);
}
public function getUser(): AuthenticationResponseUserField {
return new AuthenticationResponseUserField($this->rawUser['id'], $this->rawUser['properties']);
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
use Ely\Mojang\Response\Properties\Factory;
class AuthenticationResponseUserField {
private $id;
private $rawProperties;
public function __construct(string $id, array $rawProperties) {
$this->id = $id;
$this->rawProperties = $rawProperties;
}
public function getId(): string {
return $this->id;
}
/**
* @return \Ely\Mojang\Response\Properties\Property[]
*/
public function getProperties(): array {
return array_map([Factory::class, 'createFromProp'], $this->rawProperties);
}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
class ProfileInfo {
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $name;
/**
* @var bool
*/
private $isLegacy;
/**
* @var bool
*/
private $isDemo;
public function __construct(string $id, string $name, bool $isLegacy = false, bool $isDemo = false) {
$this->id = $id;
$this->name = $name;
$this->isLegacy = $isLegacy;
$this->isDemo = $isDemo;
}
public static function createFromResponse(array $response): self {
return new static(
$response['id'],
$response['name'],
$response['legacy'] ?? false,
$response['demo'] ?? false
);
}
/**
* @return string user's uuid without dashes
*/
public function getId(): string {
return $this->id;
}
/**
* @return string username at the current time
*/
public function getName(): string {
return $this->name;
}
/**
* @return bool true means, that account not migrated into Mojang account
*/
public function isLegacy(): bool {
return $this->isLegacy;
}
/**
* @return bool true means, that account now in demo mode (not premium user)
*/
public function isDemo(): bool {
return $this->isDemo;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
use Ely\Mojang\Response\Properties\Factory;
class ProfileResponse {
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $name;
/**
* @var array
*/
private $props;
public function __construct(string $id, string $name, array $rawProps) {
$this->id = $id;
$this->name = $name;
$this->props = $rawProps;
}
public function getId(): string {
return $this->id;
}
public function getName(): string {
return $this->name;
}
/**
* @return \Ely\Mojang\Response\Properties\Property[]
*/
public function getProps(): array {
return array_map([Factory::class, 'createFromProp'], $this->props);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class Factory {
private static $MAP = [
'textures' => TexturesProperty::class,
];
public static function createFromProp(array $prop): Property {
$name = $prop['name'];
if (isset(static::$MAP[$name])) {
$className = static::$MAP[$name];
return new $className($prop);
}
return new Property($prop);
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class Property {
/**
* @var string
*/
public $name;
/**
* @var string
*/
public $value;
public function __construct(array $prop) {
$this->name = $prop['name'];
$this->value = $prop['value'];
}
public function getName(): string {
return $this->name;
}
public function getValue(): string {
return $this->value;
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesProperty extends Property {
/**
* @var string|null
*/
private $signature;
public function __construct(array $prop) {
parent::__construct($prop);
$this->signature = $prop['signature'] ?? null;
}
public function getTextures(): TexturesPropertyValue {
return TexturesPropertyValue::createFromRawTextures($this->value);
}
public function getSignature(): ?string {
return $this->signature;
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesPropertyValue {
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $username;
/**
* @var array
*/
private $textures;
/**
* @var int
*/
private $timestamp;
/**
* @var bool
*/
private $signatureRequired;
public function __construct(
string $profileId,
string $profileName,
array $textures,
int $timestamp,
bool $signatureRequired = false
) {
$this->id = $profileId;
$this->username = $profileName;
$this->textures = $textures;
$this->timestamp = (int)floor($timestamp / 1000);
$this->signatureRequired = $signatureRequired;
}
public static function createFromRawTextures(string $rawTextures): self {
$decoded = json_decode(base64_decode($rawTextures), true);
return new static(
$decoded['profileId'],
$decoded['profileName'],
$decoded['textures'],
$decoded['timestamp'],
$decoded['signatureRequired'] ?? false
);
}
public function getProfileId(): string {
return $this->id;
}
public function getProfileName(): string {
return $this->username;
}
public function getTimestamp(): int {
return $this->timestamp;
}
public function isSignatureRequired(): bool {
return $this->signatureRequired;
}
public function getSkin(): ?TexturesPropertyValueSkin {
if (!isset($this->textures['SKIN'])) {
return null;
}
return TexturesPropertyValueSkin::createFromTextures($this->textures['SKIN']);
}
public function getCape(): ?TexturesPropertyValueCape {
if (!isset($this->textures['CAPE'])) {
return null;
}
return new TexturesPropertyValueCape($this->textures['CAPE']['url']);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesPropertyValueCape {
/**
* @var string
*/
private $url;
public function __construct(string $skinUrl) {
$this->url = $skinUrl;
}
public function getUrl(): string {
return $this->url;
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesPropertyValueSkin {
/**
* @var string
*/
private $url;
/**
* @var bool
*/
private $isSlim;
public function __construct(string $skinUrl, bool $isSlim = false) {
$this->url = $skinUrl;
$this->isSlim = $isSlim;
}
public static function createFromTextures(array $textures): self {
$model = &$textures['metainfo']['model']; // ampersand to avoid notice about unexpected key
return new static($textures['url'], $model === 'slim');
}
public function getUrl(): string {
return $this->url;
}
public function isSlim(): bool {
return $this->isSlim;
}
}

347
tests/ApiTest.php Normal file
View File

@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Test;
use Ely\Mojang\Api;
use Ely\Mojang\Exception\NoContentException;
use Ely\Mojang\Middleware\ResponseConverterMiddleware;
use Ely\Mojang\Middleware\RetryMiddleware;
use Ely\Mojang\Response\Properties\TexturesProperty;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use function GuzzleHttp\Psr7\parse_query;
class ApiTest extends TestCase {
/**
* @var Api
*/
private $api;
/**
* @var \GuzzleHttp\Handler\MockHandler
*/
private $mockHandler;
/**
* @var \Psr\Http\Message\RequestInterface[]
*/
private $history;
protected function setUp(): void {
$this->mockHandler = new MockHandler();
$handlerStack = HandlerStack::create($this->mockHandler);
$this->history = [];
$handlerStack->push(Middleware::history($this->history), 'history');
$handlerStack->after('http_errors', ResponseConverterMiddleware::create(), 'mojang_responses');
$handlerStack->push(RetryMiddleware::create(), 'retry');
$client = new Client(['handler' => $handlerStack]);
$this->api = new Api($client);
}
public function testCreate() {
$this->assertInstanceOf(Api::class, Api::create());
}
public function testUsernameToUuid() {
$this->mockHandler->append($this->createResponse(200, [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'legacy' => false,
]));
$result = $this->api->usernameToUUID('MockUsername');
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$this->assertSame('https://api.mojang.com/users/profiles/minecraft/MockUsername', (string)$request->getUri());
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $result->getId());
$this->assertSame('MockUsername', $result->getName());
$this->assertFalse($result->isLegacy());
$this->assertFalse($result->isDemo());
}
public function testUsernameToUuidWithAtParam() {
$this->mockHandler->append($this->createResponse(200, [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'legacy' => false,
]));
$this->api->usernameToUUID('MockUsername', 1553961511);
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$this->assertSame(
'https://api.mojang.com/users/profiles/minecraft/MockUsername?atTime=1553961511',
(string)$request->getUri()
);
}
public function testUuidToTextures() {
$this->mockHandler->append($this->createResponse(200, [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'properties' => [
[
'name' => 'textures',
'value' => base64_encode(json_encode([
'timestamp' => 1553961848860,
'profileId' => '86f6e3695b764412a29820cac1d4d0d6',
'profileName' => 'MockUsername',
'signatureRequired' => true,
'textures' => [
'SKIN' => [
'url' => 'http://textures.minecraft.net/texture/292009a4925b58f02c77dadc3ecef07ea4c7472f64e0fdc32ce5522489362680',
],
'CAPE' => [
'url' => 'http://textures.minecraft.net/texture/capePath',
],
],
])),
'signature' => 'mocked signature value',
],
],
]));
$result = $this->api->uuidToTextures('86f6e3695b764412a29820cac1d4d0d6');
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$this->assertSame(
'https://sessionserver.mojang.com/session/minecraft/profile/86f6e3695b764412a29820cac1d4d0d6?unsigned=0',
(string)$request->getUri()
);
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $result->getId());
$this->assertSame('MockUsername', $result->getName());
$props = $result->getProps();
/** @var TexturesProperty $texturesProperty */
$texturesProperty = $props[0];
$this->assertInstanceOf(TexturesProperty::class, $texturesProperty);
$this->assertSame('textures', $texturesProperty->getName());
$this->assertSame('mocked signature value', $texturesProperty->getSignature());
$textures = $texturesProperty->getTextures();
$this->assertSame(1553961848, $textures->getTimestamp());
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $textures->getProfileId());
$this->assertSame('MockUsername', $textures->getProfileName());
$this->assertTrue($textures->isSignatureRequired());
$this->assertNotNull($textures->getSkin());
$this->assertSame(
'http://textures.minecraft.net/texture/292009a4925b58f02c77dadc3ecef07ea4c7472f64e0fdc32ce5522489362680',
$textures->getSkin()->getUrl()
);
$this->assertFalse($textures->getSkin()->isSlim());
$this->assertSame('http://textures.minecraft.net/texture/capePath', $textures->getCape()->getUrl());
}
public function testUsernameToTextures() {
$this->mockHandler->append($this->createResponse(200, [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'legacy' => false,
]));
$this->mockHandler->append($this->createResponse(200, [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'properties' => [],
]));
$this->api->usernameToTextures('MockUsername');
/** @var \Psr\Http\Message\RequestInterface $request1 */
/** @var \Psr\Http\Message\RequestInterface $request2 */
[0 => ['request' => $request1], 1 => ['request' => $request2]] = $this->history;
$this->assertSame('https://api.mojang.com/users/profiles/minecraft/MockUsername', (string)$request1->getUri());
$this->assertStringStartsWith('https://sessionserver.mojang.com/session/minecraft/profile/86f6e3695b764412a29820cac1d4d0d6', (string)$request2->getUri());
}
public function testAuthenticate() {
$this->mockHandler->append($this->createResponse(200, [
'accessToken' => 'access token value',
'clientToken' => 'client token value',
'availableProfiles' => [
[
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'legacy' => false,
],
],
'selectedProfile' => [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'legacy' => false,
],
'user' => [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'properties' => [
[
'name' => 'preferredLanguage',
'value' => 'en',
],
[
'name' => 'twitch_access_token',
'value' => 'twitch oauth token',
],
],
],
]));
$result = $this->api->authenticate('MockUsername', 'some password', 'client token value');
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$this->assertSame('https://authserver.mojang.com/authenticate', (string)$request->getUri());
$this->assertSame('access token value', $result->getAccessToken());
$this->assertSame('client token value', $result->getClientToken());
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $result->getAvailableProfiles()[0]->getId());
$this->assertSame('MockUsername', $result->getAvailableProfiles()[0]->getName());
$this->assertFalse($result->getAvailableProfiles()[0]->isLegacy());
$this->assertFalse($result->getAvailableProfiles()[0]->isDemo());
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $result->getSelectedProfile()->getId());
$this->assertSame('MockUsername', $result->getSelectedProfile()->getName());
$this->assertFalse($result->getSelectedProfile()->isLegacy());
$this->assertFalse($result->getSelectedProfile()->isDemo());
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $result->getUser()->getId());
$this->assertSame('preferredLanguage', $result->getUser()->getProperties()[0]->getName());
$this->assertSame('en', $result->getUser()->getProperties()[0]->getValue());
$this->assertSame('twitch_access_token', $result->getUser()->getProperties()[1]->getName());
$this->assertSame('twitch oauth token', $result->getUser()->getProperties()[1]->getValue());
}
public function testAuthenticateWithNotSpecifiedClientToken() {
$this->mockHandler->append($this->createResponse(200, [
'accessToken' => 'access token value',
'clientToken' => 'client token value',
'availableProfiles' => [],
'selectedProfile' => [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'legacy' => false,
],
'user' => [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'properties' => [],
],
]));
$this->api->authenticate('MockUsername', 'some password');
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$body = json_decode($request->getBody()->getContents(), true);
// https://gist.github.com/johnelliott/cf77003f72f889abbc3f32785fa3df8d
$this->assertRegExp('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i', $body['clientToken']);
}
public function testValidateSuccessful() {
$this->mockHandler->append(new Response(204));
$this->assertTrue($this->api->validate('mocked access token'));
}
public function testValidateInvalid() {
$this->mockHandler->append(new Response(403));
$this->assertFalse($this->api->validate('mocked access token'));
}
public function testUploadSkinNotSlim() {
$this->mockHandler->append(new Response(200));
$this->api->uploadSkin('mocked access token', '86f6e3695b764412a29820cac1d4d0d6', 'skin contents', false);
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$this->assertSame('Bearer mocked access token', $request->getHeaderLine('Authorization'));
$this->assertStringNotContainsString('slim', $request->getBody()->getContents());
}
public function testUploadSkinSlim() {
$this->mockHandler->append(new Response(200));
$this->api->uploadSkin('mocked access token', '86f6e3695b764412a29820cac1d4d0d6', 'skin contents', true);
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$this->assertStringContainsString('slim', $request->getBody()->getContents());
}
public function testJoinServer() {
$this->mockHandler->append(new Response(200));
$this->api->joinServer('mocked access token', '86f6e3695b764412a29820cac1d4d0d6', 'ad72fe1efe364e6eb78c644a9fba1d30');
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$params = json_decode($request->getBody()->getContents(), true);
$this->assertSame('mocked access token', $params['accessToken']);
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $params['selectedProfile']);
$this->assertSame('ad72fe1efe364e6eb78c644a9fba1d30', $params['serverId']);
}
public function testHasJoinedServer() {
$this->mockHandler->append($this->createResponse(200, [
'id' => '86f6e3695b764412a29820cac1d4d0d6',
'name' => 'MockUsername',
'properties' => [
[
'name' => 'textures',
'value' => base64_encode(json_encode([
'timestamp' => 1553961848860,
'profileId' => '86f6e3695b764412a29820cac1d4d0d6',
'profileName' => 'MockUsername',
'signatureRequired' => true,
'textures' => [
'SKIN' => [
'url' => 'http://textures.minecraft.net/texture/292009a4925b58f02c77dadc3ecef07ea4c7472f64e0fdc32ce5522489362680',
],
],
])),
'signature' => 'mocked signature value',
],
],
]));
$result = $this->api->hasJoinedServer('MockedUsername', 'ad72fe1efe364e6eb78c644a9fba1d30');
/** @var \Psr\Http\Message\RequestInterface $request */
$request = $this->history[0]['request'];
$params = parse_query($request->getUri()->getQuery());
$this->assertSame('MockedUsername', $params['username']);
$this->assertSame('ad72fe1efe364e6eb78c644a9fba1d30', $params['serverId']);
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $result->getId());
$this->assertSame('MockUsername', $result->getName());
$props = $result->getProps();
/** @var TexturesProperty $texturesProperty */
$texturesProperty = $props[0];
$this->assertInstanceOf(TexturesProperty::class, $texturesProperty);
$this->assertSame('textures', $texturesProperty->getName());
$this->assertSame('mocked signature value', $texturesProperty->getSignature());
$textures = $texturesProperty->getTextures();
$this->assertSame(1553961848, $textures->getTimestamp());
$this->assertSame('86f6e3695b764412a29820cac1d4d0d6', $textures->getProfileId());
$this->assertSame('MockUsername', $textures->getProfileName());
$this->assertTrue($textures->isSignatureRequired());
$this->assertNotNull($textures->getSkin());
$this->assertSame(
'http://textures.minecraft.net/texture/292009a4925b58f02c77dadc3ecef07ea4c7472f64e0fdc32ce5522489362680',
$textures->getSkin()->getUrl()
);
$this->assertFalse($textures->getSkin()->isSlim());
$this->assertNull($textures->getCape());
}
public function testHasJoinedServerEmptyResponse() {
$this->mockHandler->append(new Response(200));
$this->expectException(NoContentException::class);
$this->api->hasJoinedServer('MockedUsername', 'ad72fe1efe364e6eb78c644a9fba1d30');
}
private function createResponse(int $statusCode, array $response): ResponseInterface {
return new Response($statusCode, ['content-type' => 'json'], json_encode($response));
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Test\Middleware;
use Ely\Mojang\Exception;
use Ely\Mojang\Middleware\ResponseConverterMiddleware;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ResponseConverterMiddlewareTest extends TestCase {
/**
* @param ResponseInterface $response
* @dataProvider getResponses
*/
public function testInvoke(RequestInterface $request, ResponseInterface $response, string $expectedException) {
$this->expectException($expectedException);
$handler = new MockHandler([$response]);
$middleware = new ResponseConverterMiddleware($handler);
$middleware($request, [])->wait();
}
public function getResponses(): iterable {
yield [
new Request('GET', 'http://localhost'),
new Response(204, [], ''),
Exception\NoContentException::class,
];
yield [
new Request('GET', 'http://localhost'),
new Response(
403,
['Content-Type' => 'application/json'],
'{"error":"ForbiddenOperationException","errorMessage":"Invalid token"}'
),
Exception\ForbiddenException::class,
];
yield [
new Request('GET', 'http://localhost'),
new Response(
429,
['Content-Type' => 'application/json'],
'{"error":"TooManyRequestsException","errorMessage":"The client has sent too many requests within a certain amount of time"}'
),
Exception\TooManyRequestsException::class,
];
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Test\Middleware;
use Ely\Mojang\Middleware\RetryMiddleware;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
class RetryMiddlewareTest extends TestCase {
public function testShouldRetry() {
$r = new Request('GET', 'http://localhost');
$this->assertFalse(RetryMiddleware::shouldRetry(0, $r, new Response(200), null), 'not retry on success response');
$this->assertFalse(RetryMiddleware::shouldRetry(0, $r, new Response(403), null), 'not retry on client error');
$this->assertTrue(RetryMiddleware::shouldRetry(0, $r, null, new ConnectException('', $r)), 'retry when network error happens');
$this->assertTrue(RetryMiddleware::shouldRetry(0, $r, new Response(503), null), 'retry when 50x error 1 time');
$this->assertTrue(RetryMiddleware::shouldRetry(1, $r, new Response(503), null), 'retry when 50x error 2 time');
$this->assertFalse(RetryMiddleware::shouldRetry(2, $r, new Response(503), null), 'don\'t retry when 50x error 3 time');
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Test\Response\Properties;
use Ely\Mojang\Response\Properties\Factory;
use Ely\Mojang\Response\Properties\Property;
use Ely\Mojang\Response\Properties\TexturesProperty;
use PHPUnit\Framework\TestCase;
class FactoryTest extends TestCase {
/**
* @param array $inputProps
* @param string $expectedType
*
* @dataProvider getProps
*/
public function testCreate(array $inputProps, string $expectedType) {
$this->assertInstanceOf($expectedType, Factory::createFromProp($inputProps));
}
public function getProps(): iterable {
yield [[
'name' => 'textures',
'value' => 'value',
'signature' => '123',
], TexturesProperty::class];
yield [[
'name' => 'other',
'value' => 'value',
], Property::class];
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Test\Response\Properties;
use Ely\Mojang\Response\Properties\TexturesPropertyValue;
use PHPUnit\Framework\TestCase;
class TexturesPropertyValueTest extends TestCase {
public function testGetSkin() {
$object = new TexturesPropertyValue('', '', [
'SKIN' => [
'url' => 'skin url',
],
], 0);
$this->assertNotNull($object->getSkin());
$this->assertSame('skin url', $object->getSkin()->getUrl());
$this->assertFalse($object->getSkin()->isSlim());
$object = new TexturesPropertyValue('', '', [
'SKIN' => [
'url' => 'skin url',
'metainfo' => [
'model' => 'slim',
],
],
], 0);
$this->assertNotNull($object->getSkin());
$this->assertSame('skin url', $object->getSkin()->getUrl());
$this->assertTrue($object->getSkin()->isSlim());
$object = new TexturesPropertyValue('', '', [], 0);
$this->assertNull($object->getSkin());
}
public function testGetCape() {
$object = new TexturesPropertyValue('', '', [
'CAPE' => [
'url' => 'cape url',
],
], 0);
$this->assertNotNull($object->getCape());
$this->assertSame('cape url', $object->getCape()->getUrl());
$object = new TexturesPropertyValue('', '', [], 0);
$this->assertNull($object->getCape());
}
}