├── src ├── .gitkeep ├── Exception │ ├── MissingServerAddressException.php │ └── MissingClientCredentialsException.php ├── DebugLogger.php ├── Commands │ └── Authenticate.php ├── Paginator.php ├── PowerSchoolApiServiceProvider.php ├── config.php ├── Facades │ └── PowerSchool.php ├── Request.php ├── Response.php └── RequestBuilder.php ├── .gitignore ├── .gitattributes ├── .editorconfig ├── tests ├── AccessorToPrivate.php └── PowerSchoolTest.php ├── phpunit.xml.dist ├── LICENSE ├── composer.json └── README.md /src/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | /build/ 3 | /vendor/ 4 | /composer.lock 5 | .env 6 | /.php_cs.cache 7 | .phpunit.result.cache 8 | .phpunit.cache 9 | phpunit.xml.dist.bak 10 | -------------------------------------------------------------------------------- /src/Exception/MissingServerAddressException.php: -------------------------------------------------------------------------------- 1 | purple(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/AccessorToPrivate.php: -------------------------------------------------------------------------------- 1 | $attribute; 12 | }; 13 | 14 | return Closure::bind($getter, $obj, get_class($obj)); 15 | } 16 | 17 | public function set($obj, $attribute) { 18 | $setter = function($value) use ($attribute) { 19 | $this->$attribute = $value; 20 | }; 21 | 22 | return Closure::bind($setter, $obj, get_class($obj)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | 10 | . 11 | 12 | 13 | ./tests 14 | ./vendor 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Commands/Authenticate.php: -------------------------------------------------------------------------------- 1 | authenticate(true); 34 | 35 | $this->info('Auth token cached!'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Paginator.php: -------------------------------------------------------------------------------- 1 | builder = $builder->pageSize($pageSize); 14 | } 15 | 16 | public function page(): ?Response 17 | { 18 | $results = $this->builder 19 | ->page($this->page) 20 | ->send(false); 21 | 22 | // This means that PS sent back a single record 23 | // and should be wrapped in an array 24 | if (!$results->isEmpty() && !$results[0]) { 25 | $results->setData([$results->data]); 26 | } 27 | 28 | if ($results->isEmpty()) { 29 | $this->page = 1; 30 | return null; 31 | } 32 | 33 | $this->page += 1; 34 | 35 | return $results; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Grant Holle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grantholle/powerschool-api", 3 | "description": "A Laravel package to make interacting with PowerSchool less painful.", 4 | "keywords": ["powerschool"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Grant Holle", 9 | "homepage": "https://grantholle.com", 10 | "role": "Developer" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.2", 15 | "ext-json": "*", 16 | "guzzlehttp/guzzle": "^7.0.1", 17 | "illuminate/console": "^10.0|^11.0|^12.0", 18 | "illuminate/support": "^10.0|^11.0|^12.0" 19 | }, 20 | "require-dev": { 21 | "orchestra/testbench": "^9.0", 22 | "spatie/ray": "^1.30" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "GrantHolle\\PowerSchool\\Api\\": "src" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "Tests\\": "tests" 32 | } 33 | }, 34 | "config": { 35 | "sort-packages": true 36 | }, 37 | "scripts": { 38 | "test": "vendor/bin/phpunit" 39 | }, 40 | "extra": { 41 | "laravel": { 42 | "providers": [ 43 | "GrantHolle\\PowerSchool\\Api\\PowerSchoolApiServiceProvider" 44 | ], 45 | "aliases": { 46 | "PowerSchool": "GrantHolle\\PowerSchool\\Api\\Facades\\PowerSchool" 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/PowerSchoolApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(RequestBuilder::class, fn () => new RequestBuilder( 21 | config('powerschool.server_address'), 22 | config('powerschool.client_id'), 23 | config('powerschool.client_secret'), 24 | config('powerschool.cache_key') 25 | )); 26 | 27 | $this->mergeConfigFrom(__DIR__ . '/config.php', 'powerschool'); 28 | } 29 | 30 | /** 31 | * Perform post-registration booting of services. 32 | * 33 | * @return void 34 | */ 35 | public function boot() 36 | { 37 | // Publish the configuration and migration 38 | $this->publishes([ 39 | __DIR__ . '/config.php' => config_path('powerschool.php'), 40 | ], ['config', 'powerschool-config']); 41 | 42 | // Commands 43 | if ($this->app->runningInConsole()) { 44 | $this->commands([ 45 | Authenticate::class 46 | ]); 47 | } 48 | } 49 | 50 | /** 51 | * Get the services provided by the provider. 52 | * 53 | * @return array 54 | */ 55 | public function provides(): array 56 | { 57 | return [RequestBuilder::class]; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | (bool) env('POWERSCHOOL_DEBUG', true), 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Cache key 19 | |-------------------------------------------------------------------------- 20 | | 21 | | This is the key used to store authentication tokens in the cache. 22 | | 23 | */ 24 | 25 | 'cache_key' => env('POWERSCHOOL_CACHE_KEY', 'powerschool_token'), 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Server Address 30 | |-------------------------------------------------------------------------- 31 | | 32 | | The fully qualified host name (including https) or IP address of your PowerSchool instance 33 | | 34 | */ 35 | 36 | 'server_address' => env('POWERSCHOOL_ADDRESS'), 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Client ID and Secret 41 | |-------------------------------------------------------------------------- 42 | | 43 | | The values of the client ID and secret obtained by installing a plugin 44 | | with in the plugin's plugin.xml manifest. 45 | | 46 | */ 47 | 48 | 'client_id' => env('POWERSCHOOL_CLIENT_ID'), 49 | 50 | 'client_secret' => env('POWERSCHOOL_CLIENT_SECRET'), 51 | ]; 52 | -------------------------------------------------------------------------------- /src/Facades/PowerSchool.php: -------------------------------------------------------------------------------- 1 | client = new Client(['base_uri' => $serverAddress]); 32 | $this->clientId = $clientId; 33 | $this->clientSecret = $clientSecret; 34 | $this->cacheKey = $cacheKey; 35 | 36 | if ($this->cacheKey) { 37 | $this->authToken = Cache::get($this->cacheKey, false); 38 | } 39 | } 40 | 41 | /** 42 | * Makes an api call to PowerSchool 43 | */ 44 | public function makeRequest(string $method, string $endpoint, array $options, bool $returnResponse = false): JsonResponse|array 45 | { 46 | $this->authenticate(); 47 | $this->attempts++; 48 | 49 | if (!isset($options['headers'])) { 50 | $options['headers'] = []; 51 | } 52 | 53 | // Force json 54 | $options['headers']['Accept'] = 'application/json'; 55 | $options['headers']['Content-Type'] = 'application/json'; 56 | 57 | // Add the auth token for the header 58 | $options['headers']['Authorization'] = 'Bearer ' . $this->authToken; 59 | 60 | // Throw exceptions for 4xx and 5xx errors 61 | $options['http_errors'] = true; 62 | 63 | DebugLogger::log(fn () => ray($endpoint, $options)->red()->label("Request meta [{$method}]")); 64 | 65 | try { 66 | $response = $this->getClient() 67 | ->request($method, $endpoint, $options); 68 | } catch (ClientException $exception) { 69 | $response = $exception->getResponse(); 70 | 71 | // If the response is an expired token, reauthenticate and try again 72 | if ($response->getStatusCode() === 401 && $this->attempts < 3) { 73 | return $this->authenticate(true) 74 | ->makeRequest($method, $endpoint, $options); 75 | } 76 | 77 | DebugLogger::log(fn () => ray()->json($response->getBody()->getContents())->red()->label($response->getStatusCode())); 78 | 79 | throw $exception; 80 | } 81 | 82 | $this->attempts = 0; 83 | $body = json_decode($response->getBody()->getContents(), true); 84 | DebugLogger::log($body); 85 | 86 | if ($returnResponse) { 87 | return LaravelResponse::json($body, $response->getStatusCode()); 88 | } 89 | 90 | return $body ?? []; 91 | } 92 | 93 | /** 94 | * Authenticates against the api and retrieves an auth token 95 | * 96 | * @param boolean $force Force authentication even if there is an existing token 97 | * @return $this 98 | * @throws MissingClientCredentialsException|\GuzzleHttp\Exception\GuzzleException 99 | */ 100 | public function authenticate(bool $force = false): static 101 | { 102 | // Check if there is already a token and we're not doing a force-retrieval 103 | if (!$force && $this->authToken) { 104 | return $this; 105 | } 106 | 107 | // Double check that there are client credentials 108 | if (!$this->clientId || !$this->clientSecret) { 109 | throw new MissingClientCredentialsException('Missing either client ID or secret. Cannot authenticate with PowerSchool API.'); 110 | } 111 | 112 | $token = base64_encode($this->clientId . ':' . $this->clientSecret); 113 | 114 | $headers = [ 115 | 'Content-Type' => 'application/x-www-form-urlencoded;charset=UTF-8', 116 | 'Accept' => 'application/json', 117 | 'Authorization' => 'Basic ' . $token, 118 | ]; 119 | 120 | // Retrieve the access token 121 | $response = $this->getClient() 122 | ->post('/oauth/access_token', [ 123 | 'headers' => $headers, 124 | 'body' => 'grant_type=client_credentials' 125 | ]); 126 | 127 | $json = json_decode($response->getBody()->getContents()); 128 | 129 | // Set and cache the auth token 130 | $this->authToken = $json->access_token; 131 | 132 | if ($this->cacheKey) { 133 | Cache::put($this->cacheKey, $this->authToken, now()->addSeconds((int) $json->expires_in)); 134 | } 135 | 136 | return $this; 137 | } 138 | 139 | public function getClient(): Client 140 | { 141 | return $this->client; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | tableName = strtolower($data['name'] ?? ''); 24 | 25 | $this->originalData = $data; 26 | 27 | // For endpoint requests that end in a number 28 | $this->isSingleItem = is_numeric($key) && count($data) === 1; 29 | 30 | $this->data = $this->inferData($data, strtolower($key)); 31 | DebugLogger::log(fn () => ray($key)->purple()->label('Guessed key')); 32 | DebugLogger::log(fn () => ray($this->originalData)->purple()->label('Original data')); 33 | DebugLogger::log(fn () => ray($this->data)->purple()->label('Response data')); 34 | } 35 | 36 | protected function inferData(array $data, string $key): array 37 | { 38 | if (empty($data)) { 39 | return []; 40 | } 41 | 42 | // Check for the results key early, 43 | // which would be the case for non-get requests 44 | if (isset($data['results'])) { 45 | return $data['results']; 46 | } 47 | 48 | if ($nested = Arr::get($data, $key . 's')) { 49 | return $this->inferData($nested, $key); 50 | } 51 | 52 | $keys = array_keys($data); 53 | 54 | // Check to see if every key is numeric. 55 | // If it is, then keep the original data as-is. 56 | if (count(array_filter($keys, 'is_numeric')) === count($keys)) { 57 | return $data; 58 | } 59 | 60 | // Remove anything that isn't the desired key 61 | // from data, but preserving as a property or meta 62 | $meta = ['@extensions', '@expansions']; 63 | foreach ($meta as $dataKey) { 64 | $this->setMeta($data, $dataKey); 65 | unset($data[$dataKey]); 66 | } 67 | 68 | if (isset($data[$key])) { 69 | return $data[$key]; 70 | } 71 | 72 | if (count(array_keys($keys)) === 1) { 73 | $first = Arr::first($data); 74 | 75 | // If this is an array, keep drilling 76 | if (is_array($first)) { 77 | return $this->inferData($first, ''); 78 | } 79 | } 80 | 81 | return $data; 82 | } 83 | 84 | public function setData(array $data): static 85 | { 86 | $this->data = $data; 87 | 88 | return $this; 89 | } 90 | 91 | public function getOriginalData(): array 92 | { 93 | return $this->originalData; 94 | } 95 | 96 | public function getMeta(): array 97 | { 98 | return $this->meta; 99 | } 100 | 101 | public function squashTableResponse(): static 102 | { 103 | if (!$this->tableName) { 104 | return $this; 105 | } 106 | $isAssoc = Arr::isAssoc($this->data); 107 | 108 | if ($isAssoc) { 109 | $this->data = [$this->data]; 110 | } 111 | 112 | $this->data = array_map( 113 | fn (array $datum) => $datum['tables'][$this->tableName], 114 | $this->data 115 | ); 116 | 117 | if ($isAssoc) { 118 | $this->data = Arr::first($this->data); 119 | } 120 | 121 | return $this; 122 | } 123 | 124 | protected function setMeta(array $data, string $property): static 125 | { 126 | $clean = $this->cleanProperty($property); 127 | $value = Arr::get($data, $property); 128 | 129 | if (in_array($clean, ['extensions', 'expansions'])) { 130 | $this->$clean = $this->splitCommaString($value); 131 | return $this; 132 | } 133 | 134 | $this->meta[$clean] = $value; 135 | 136 | return $this; 137 | } 138 | 139 | protected function cleanProperty(string $property): string 140 | { 141 | return preg_replace("/[^a-zA-Z0-9_]/u", '', $property); 142 | } 143 | 144 | protected function splitCommaString(?string $string): array 145 | { 146 | if (!$string) { 147 | return []; 148 | } 149 | 150 | $parts = explode(',', $string); 151 | 152 | return array_map(fn ($s) => trim($s), $parts); 153 | } 154 | 155 | public function isEmpty(): bool 156 | { 157 | return empty($this->data); 158 | } 159 | 160 | public function count(): int 161 | { 162 | return count($this->data); 163 | } 164 | 165 | public function current(): mixed 166 | { 167 | $current = $this->data[$this->index] ?? null; 168 | 169 | if (!$current || !Arr::has($current, 'tables')) { 170 | return $current; 171 | } 172 | 173 | return Arr::get($current, "tables.{$this->tableName}"); 174 | } 175 | 176 | public function next(): void 177 | { 178 | $this->index++; 179 | } 180 | 181 | public function key(): int 182 | { 183 | return $this->index; 184 | } 185 | 186 | public function valid(): bool 187 | { 188 | return isset($this->data[$this->index]); 189 | } 190 | 191 | public function rewind(): void 192 | { 193 | $this->index = 0; 194 | } 195 | 196 | public function offsetExists($offset): bool 197 | { 198 | return isset($this->data[$offset]); 199 | } 200 | 201 | public function offsetGet($offset): mixed 202 | { 203 | return Arr::get($this->data, $offset); 204 | } 205 | 206 | public function offsetSet($offset, $value): void 207 | { 208 | if (is_null($offset)) { 209 | $this->data[] = $value; 210 | } else { 211 | $this->data[$offset] = $value; 212 | } 213 | } 214 | 215 | public function offsetUnset($offset): void 216 | { 217 | unset($this->data[$offset]); 218 | } 219 | 220 | public function __get(string $name): mixed 221 | { 222 | return $this->data[$name] ?? null; 223 | } 224 | 225 | public function toArray(): array 226 | { 227 | return $this->data; 228 | } 229 | 230 | public function toJson(): string 231 | { 232 | return json_encode($this->data); 233 | } 234 | 235 | public function collect(): Collection 236 | { 237 | return collect($this->data); 238 | } 239 | 240 | public function __serialize(): array 241 | { 242 | return [ 243 | 'data' => $this->data, 244 | 'table_name' => $this->tableName, 245 | 'expansions' => $this->expansions, 246 | 'extensions' => $this->extensions, 247 | ]; 248 | } 249 | 250 | public function __unserialize(array $data): void 251 | { 252 | $this->data = $data['data'] ?? []; 253 | $this->tableName = $data['table_name'] ?? null; 254 | $this->expansions = $data['expansions'] ?? []; 255 | $this->extensions = $data['extensions'] ?? []; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PowerSchool API 2 | 3 | Taking inspiration from Laravel's database and Eloquent `Builder` class, this allows you to make api requests against PowerSchool very fluently and naturally. It handles token authentication automatically, so you can just worry about writing the requests and not the boilerplate. 4 | 5 | This package is to be used with alongside a PowerSchool plugin that has enabled `` in the `plugin.xml`. This guide assumes you have PowerSchool API and plugin knowledge and does not cover the details of a plugin or its API. 6 | 7 | ## Breaking changes for v4 8 | 9 | - Requires PHP ^8.1 10 | - Requires Laravel ^10.0 11 | 12 | ## Breaking changes for v3 13 | 14 | - Requires PHP ^8.0 15 | - Requests return a new `Response` instead of `stdClass`, [see below](#responses) for details 16 | 17 | ## Breaking changes for v2 18 | 19 | - SSO functionality has been abstracted to a new package, [`grantholle/laravel-powerschool-auth`](https://github.com/grantholle/laravel-powerschool-auth) 20 | - The namespace is now `GrantHolle\PowerSchool\Api` 21 | 22 | More functionality was added in v2, along with tests for peace of mind. 23 | 24 | ## Installation 25 | 26 | ```bash 27 | composer require grantholle/powerschool-api 28 | ``` 29 | 30 | The package will be automatically discovered by Laravel, so there's no reason to add it to `config/app.php` unless you want to. 31 | 32 | ## Configuration 33 | 34 | You need to set some variables in `.env`. 35 | 36 | ``` 37 | POWERSCHOOL_ADDRESS= 38 | POWERSCHOOL_CLIENT_ID= 39 | POWERSCHOOL_CLIENT_SECRET= 40 | ``` 41 | 42 | Optionally, you can publish the config file to store the server address, client ID, and secret to interact with PowerSchool. This will generate `config/powerschool.php`, but is not necessary. 43 | 44 | ```bash 45 | php artisan vendor:publish --provider="GrantHolle\PowerSchool\Api\PowerSchoolApiServiceProvider" 46 | ``` 47 | 48 | ## Debugging 49 | 50 | You can enable debugging with [Ray](https://myray.app/) that will display the raw and transformed responses for each request. This is helpful in viewing the response from PowerSchool and the `GrantHolle\PowerSchool\Api\Response` object's data. You will need to install the [Laravel package](https://spatie.be/docs/ray/v1/installation-in-your-project/laravel) and enable debugging: 51 | 52 | ``` 53 | # App debug needs to be enabled also 54 | APP_DEBUG=true 55 | 56 | POWERSCHOOL_DEBUG=true 57 | ``` 58 | 59 | ## Commands 60 | 61 | ```bash 62 | # Fetches authorization token and caches it 63 | php artisan powerschool:auth 64 | ``` 65 | 66 | ## API 67 | 68 | Using the facade, `GrantHolle\PowerSchool\Api\Facades\PowerSchool`, you can fluently build a request for PowerSchool. By also providing several aliases to key functions, you can write requests in a way that feels comfortable to you and is easy to read. Below are examples that build on each other. See examples below to put them all together. 69 | 70 | #### `setTable(string $table)` 71 | 72 | _Aliases: table(), forTable(), againstTable()_ 73 | 74 | This "sets" the table with which you're interacting. Applies to database extensions. 75 | 76 | ```php 77 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 78 | 79 | $request = PowerSchool::table('u_my_custom_table'); 80 | ``` 81 | 82 | #### `setId($id)` 83 | 84 | _Aliases: id(), forId()_ 85 | 86 | Sets the id for a get, put, or delete request when interacting with a specific entry in the database. 87 | 88 | ```php 89 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 90 | 91 | $request = PowerSchool::table('u_my_custom_table')->forId(100); 92 | ``` 93 | 94 | #### `setMethod(string $method)` 95 | 96 | _Aliases: usingMethod(), get(), post(), put(), patch(), delete()_ 97 | 98 | Sets the HTTP verb for the request. When using the functions `get()`, `post()`, `put()`, `patch()`, or `delete()`, the request is sent automatically. 99 | 100 | ```php 101 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 102 | 103 | // This request is still not sent 104 | $request = PowerSchool::table('u_my_custom_table')->setId(100)->method('get'); 105 | 106 | // This request is set to the get method and sent automatically 107 | $response = PowerSchool::table('u_my_custom_table')->id(100)->get(); 108 | $response = PowerSchool::table('u_my_custom_table')->id(100)->get(); 109 | $response = PowerSchool::table('u_my_custom_table')->id(100)->delete(); 110 | 111 | // The above example could be rewritten like this... 112 | $response = PowerSchool::table('u_my_custom_table')->id(100)->setMethod('get')->send(); 113 | ``` 114 | 115 | #### `setData(Array $data)` 116 | 117 | _Aliases: withData(), with()_ 118 | 119 | Sets the data that gets sent with requests. If it's for a custom table, you can just send the fields and their values and the structure that is compatible with PowerSchool is build automatically. If it's a named query, it's just the `args` that have been configured with the query. 120 | 121 | ```php 122 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 123 | 124 | $data = [ 125 | 'column_one' => 'value', 126 | 'column_two' => 'value', 127 | ]; 128 | 129 | // Doing "table" requests, this not getting sent 130 | $request = PowerSchool::table('u_my_custom_table')->with($data)->method('post'); 131 | 132 | // A PowerQuery (see below) 133 | $response = PowerSchool::pq('com.organization.product.area.name')->withData($data); 134 | ``` 135 | 136 | #### `setNamedQuery(string $query, Array $data = [])` 137 | 138 | _Aliases: namedQuery(), powerQuery(), pq()_ 139 | 140 | The first parameter is the name of the query, following the required convention set forth by PowerSchool, `com.organization.product.area.name`. The second is the data that you may need to perform the query which has been configured in the plugin's named query xml file. If the data is included, the request is sent automatically. 141 | 142 | ```php 143 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 144 | 145 | // Will not get sent 146 | $request = PowerSchool::powerQuery('com.organization.product.area.name'); 147 | 148 | // Gets posted automatically 149 | $response = PowerSchool::powerQuery('com.organization.product.area.name', ['schoolid' => '100']); 150 | ``` 151 | 152 | #### `setEndpoint(string $query)` 153 | 154 | _Aliases: toEndpoint(), to(), endpoint()_ 155 | 156 | Sets the endpoint for core PS resources. 157 | 158 | ```php 159 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 160 | 161 | $requestData = [ 162 | 'students' => [ 163 | 'student' => [ 164 | 'client_uid' => 100, 165 | 'action' => 'INSERT', 166 | 'local_id' => 100, 167 | 'name' => [ 168 | 'first_name' => 'John', 169 | 'last_name' => 'Doe', 170 | ], 171 | 'demographics' => [ 172 | 'gender' => 'M', 173 | 'birth_date' => '2002-08-01', 174 | ], 175 | 'school_enrollment' => [ 176 | 'entry_date' => now()->format('Y-m-d'), 177 | 'exit_date' => now()->subDays(1)->format('Y-m-d'), 178 | 'grade_level' => 10, 179 | 'school_number' => 100, 180 | ], 181 | ], 182 | ], 183 | ]; 184 | 185 | $response = PowerSchool::toEndpoint('/ws/v1/student')->with($requestData)->post(); 186 | ``` 187 | 188 | #### `q(string $expression)` 189 | 190 | _Aliases: queryExpression()_ 191 | 192 | Sets the `q` variable to the given FIQL expression. 193 | 194 | ```php 195 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 196 | 197 | PowerSchool::endpoint('/ws/v1/school/3/student') 198 | ->q('name.last_name==Ada*') 199 | ->get(); 200 | ``` 201 | 202 | #### `adHocFilter(string $expression)` 203 | 204 | _Aliases: filter()_ 205 | 206 | Sets the `$q` query variable for adding ad-hoc filtering to PowerQueries. 207 | 208 | ```php 209 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 210 | 211 | PowerSchool::pq('com.organization.plugin_name.entity.query_name') 212 | ->filter('number_column=lt=100') 213 | ->post(); 214 | ``` 215 | 216 | #### `adHocOrder(string $expression)` 217 | 218 | _Aliases: order()_ 219 | 220 | Sets the `order` query variable for adding [ad-hoc ordering](https://support.powerschool.com/developer/#/page/powerqueries#adhoc_ordering) to PowerQueries. 221 | 222 | ```php 223 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 224 | 225 | PowerSchool::pq('com.organization.plugin_name.entity.query_name') 226 | ->order('students.last_name,students.first_name,students.entrydate;desc') 227 | ->post(); 228 | ``` 229 | 230 | #### `pageSize(int $pageSize)` 231 | 232 | Sets the `pagesize` query variable. 233 | 234 | #### `page(int $page)` 235 | 236 | Sets the `page` query variable for pagination. 237 | 238 | #### `sort(string|array $columns, bool $descending = false)` 239 | 240 | Sets the `sort` and `sortdescending` variables. 241 | 242 | ```php 243 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 244 | 245 | PowerSchool::pq('com.organization.plugin_name.entity.query_name') 246 | ->sort('column1'); 247 | 248 | // ?sort=column1&sortdescending=false 249 | ``` 250 | 251 | #### `includeCount()` 252 | 253 | Includes the count of all the records in the results. 254 | 255 | ```php 256 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 257 | 258 | PowerSchool::pq('com.pearson.core.guardian.student_guardian_detail') 259 | ->includeCount() 260 | ->post(); 261 | 262 | // { 263 | // "name":"Students", 264 | // "count":707625, 265 | // "record":[ 266 | // { 267 | // "id":3328, 268 | // "name":"Students", 269 | // ... 270 | // }, 271 | // ... Only first page of actual results returned 272 | // ], 273 | // "@extensions":"activities,u_dentistry,studentcorefields,c_studentlocator" 274 | // } 275 | ``` 276 | 277 | #### `withQueryString(string|array $queryString)` 278 | 279 | _Aliases: query()_ 280 | 281 | This will set the query string en masse rather than using the convenience methods. 282 | 283 | #### `projection(string|array $projection)` 284 | 285 | Sets the `projection` query variable for the request. 286 | 287 | #### `excludeProjection()` 288 | 289 | _Aliases: withoutProjection()_ 290 | 291 | Prevents the `projection` query variable from being included in the request. 292 | 293 | #### `dataVersion(int $version, string $applicationName)` 294 | 295 | _Aliases: withDataVersion()_ 296 | 297 | Sets the `$dataversion` and `$dataversion_applicationname` data items. 298 | 299 | #### `expansions(string|array $expansions)` 300 | 301 | _Aliases: withExpansions()_ 302 | 303 | Adds the `expansions` query variable. 304 | 305 | #### `extensions(string|array $expansions)` 306 | 307 | _Aliases: withExtensions()_ 308 | 309 | Adds the `extensions` query variable. 310 | 311 | ## Performing Requests 312 | 313 | There are many ways to perform the request after building queries. At the end of the day, each one sets the method/HTTP verb before calling `send()`. If you'd like to call `send()`, make sure you set the method by calling `method(string $verb)`. There are also helpers to set methods using constants. 314 | 315 | ```php 316 | use GrantHolle\PowerSchool\Api\RequestBuilder; 317 | 318 | RequestBuilder::GET; 319 | RequestBuilder::POST; 320 | RequestBuilder::PUT; 321 | RequestBuilder::PATCH; 322 | RequestBuilder::DELETE; 323 | ``` 324 | 325 | #### `send()` 326 | 327 | Sends the request using the verb set. By default will return the results from the query. You can also call `asJsonResponse()` prior to sending to get an instance of Laravel's `JsonResponse` class which could be returned directly to the client. 328 | 329 | #### `count()` 330 | 331 | Calling `count()` on the builder will perform a count query by appending `/count` to the end of the endpoint and perform the `get` request automatically. 332 | 333 | #### `get(string $endpoint = null)` 334 | 335 | Sets the verb to be `get` and sends the request. You can also pass the endpoint directly to set the endpoint and perform the request automatically. 336 | 337 | ```php 338 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 339 | 340 | PowerSchool::get('/ws/v1/staff/111'); 341 | ``` 342 | 343 | #### `post()` 344 | 345 | Sets the verb to be `post` and sends the request. 346 | 347 | #### `put()` 348 | 349 | Sets the verb to be `put` and sends the request. 350 | 351 | #### `path()` 352 | 353 | Sets the verb to be `path` and sends the request. 354 | 355 | #### `delete()` 356 | 357 | Sets the verb to be `delete` and sends the request. 358 | 359 | #### `getDataSubscriptionChanges(string $applicationName, int $version)` 360 | 361 | Performs the "delta pull" for a data version subscription. 362 | 363 | ```php 364 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 365 | 366 | PowerSchool::getDataSubscriptionChanges('myapp', 12345); 367 | 368 | // { 369 | // "$dataversion": "16323283", 370 | // "tables": { 371 | // "userscorefields": [ 372 | // 802 373 | // ], 374 | // "users": [ 375 | // 851, 376 | // 769, 377 | // 802, 378 | // 112 379 | // ] 380 | // } 381 | // } 382 | ``` 383 | 384 | ## Pagination 385 | 386 | When using PowerQueries, you can easily paginate results using the `$builder->paginate($pageSize)` function. You can use this inside of a `while` loop to process all the results in your query more efficiently than returning the full result. The default page size is 100. 387 | 388 | ```php 389 | use GrantHolle\PowerSchool\Api\Facades\PowerSchool; 390 | 391 | // PowerQuery 392 | // You have to set data in a separate function call 393 | // Otherwise the request will be sent automatically 394 | $builder = PowerSchool::pq('com.organization.plugin_name.entity.query_name') 395 | ->with(['some_variable' => $value]); 396 | 397 | // "Normal" endpoint 398 | $builder = PowerSchool::to('/ws/v1/school/1/course') 399 | ->method(PowerSchool::GET); 400 | 401 | // "Table" endpoints 402 | $builder = PowerSchool::table('u_my_table') 403 | ->method(PowerSchool::GET); 404 | 405 | while ($records = $builder->paginate(25)) { 406 | // Do something awesome 407 | } 408 | ``` 409 | 410 | ## Responses 411 | 412 | Prior to `v3`, API requests returned a simple `stdClass` instance containing the raw response from PowerSchool. Since `v3`, there's a new `GrantHolle\PowerSchool\Api\Response` class that gets returned, which implements `ArrayAccess`. This gives you a more ergonomic way of handling the data that gets returned from PowerSchool. Depending on the request you're making, PowerSchool returns a variety of keys and data nesting. The new `Response` class attempts to normalize the data that gets returned, making it much simpler for you to process. 413 | 414 | ## Singular responses 415 | 416 | Some responses are meant to return a single record, such as a response for `/ws/contacts/contact/{id}`. For these responses, the properties can be accessed as an associative array. 417 | 418 | ```php 419 | $response = PowerSchool::to('/ws/contacts/contact/123') 420 | ->get(); 421 | 422 | // Since Response implements ArrayAccess, we can access the attributes with keys 423 | $response['contactId']; // 123 424 | ``` 425 | 426 | The `@extensions` and `@expansions` fields will be parsed into `$extensions` and `$expansions` properties as arrays. 427 | 428 | ```php 429 | $response->extensions; 430 | // [ 431 | // "personcorefields", 432 | // ] 433 | ``` 434 | 435 | ## List responses 436 | 437 | For the responses that return a listing of results, the `Response` can be iterated using `foreach`. You don't need to worry about the property nesting, as the response will be inferred from the type of response. 438 | 439 | ```php 440 | $results = PowerSchool::to('/ws/v1/district/school') 441 | ->get(); 442 | 443 | foreach ($results as $result) { 444 | // $result will be an array representing the school object returned 445 | } 446 | ``` 447 | 448 | For `get` table listings, the results are nested awkwardly. For example, 449 | 450 | ```php 451 | PowerSchool::table('u_my_table')->get(); 452 | 453 | // This returns results like 454 | [ 455 | [ 456 | 'id' => 1, 457 | 'tables' => [ 458 | 'u_my_table' => [ 459 | 'column' => '', 460 | 'column' => '', 461 | // etc 462 | ] 463 | ] 464 | ], 465 | // and on and on 466 | ] 467 | ``` 468 | 469 | We can reduce the awkwardness of the results by calling `squashTableResponse()` on the `Response` object. 470 | 471 | ```php 472 | PowerSchool::table('u_my_table') 473 | ->get() 474 | ->squashTableResponse(); 475 | 476 | // Now the results will be simpler 477 | [ 478 | [ 479 | 'column' => '', 480 | 'column' => '', 481 | // etc 482 | ], 483 | // and on and on 484 | ] 485 | ``` 486 | 487 | ## License 488 | 489 | [MIT](LICENSE) 490 | 491 | ## Contributing 492 | 493 | Thanks for taking the time to submit an issue or pull request. If it's a new feature, do your best to add a test to cover the functionality. Then run: 494 | 495 | ```bash 496 | ./vendor/bin/phpunit 497 | ``` 498 | -------------------------------------------------------------------------------- /tests/PowerSchoolTest.php: -------------------------------------------------------------------------------- 1 | accessor = new AccessorToPrivate(); 36 | Cache::forget(static::CACHE_KEY); 37 | } 38 | 39 | protected function getApplicationProviders($application) 40 | { 41 | return [ 42 | PowerSchoolApiServiceProvider::class, 43 | CacheServiceProvider::class, 44 | FilesystemServiceProvider::class, 45 | ]; 46 | } 47 | 48 | protected function getEnvironmentSetUp($application) 49 | { 50 | $application['config']->set('powerschool.server_address', 'https://test.powerschool.com'); 51 | $application['config']->set('powerschool.client_id', '45d8d083-40e1-43da-904f-94c91968523d'); 52 | $application['config']->set('powerschool.client_secret', '86954e3d-49cb-41ba-80e9-cd17d4772f89'); 53 | } 54 | 55 | protected function getGuzzleMock(MockHandler $handler): Client 56 | { 57 | $handlerStack = HandlerStack::create($handler); 58 | 59 | return new Client(['handler' => $handlerStack]); 60 | } 61 | 62 | /** 63 | * @param MockHandler $handler 64 | * @param array $methods 65 | * @return Request|\PHPUnit\Framework\MockObject\MockObject 66 | */ 67 | protected function getRequestMock(MockHandler $handler, array $methods = ['getClient', 'authenticate']) 68 | { 69 | $mock = $this->getMockBuilder(Request::class) 70 | ->onlyMethods($methods) 71 | ->setConstructorArgs(self::CONSTRUCTOR_ARGS) 72 | ->getMock(); 73 | 74 | $mock->expects($this->any()) 75 | ->method('getClient') 76 | ->willReturn($this->getGuzzleMock($handler)); 77 | 78 | return $mock; 79 | } 80 | 81 | /** 82 | * @param MockHandler|null $handler 83 | * @param null $requestMock 84 | * @return RequestBuilder 85 | */ 86 | protected function getRequestBuilderMock(MockHandler $handler = null, $requestMock = null) 87 | { 88 | $requestMock = $requestMock ?: $this->getRequestMock($handler); 89 | 90 | $mock = $this->getMockBuilder(RequestBuilder::class) 91 | ->onlyMethods(['getRequest']) 92 | ->setConstructorArgs(self::CONSTRUCTOR_ARGS) 93 | ->getMock(); 94 | 95 | $mock->expects($this->any()) 96 | ->method('getRequest') 97 | ->willReturn($requestMock); 98 | 99 | return $mock; 100 | } 101 | 102 | protected function getAuthResponse() 103 | { 104 | $response = '{"access_token":"' . self::AUTH_TOKEN .'","expires_in":3600}'; 105 | 106 | return new Response(200, [], $response); 107 | } 108 | 109 | public function test_can_create_request_builder_with_app() 110 | { 111 | $builder = app()->make(RequestBuilder::class); 112 | $this->assertInstanceOf(RequestBuilder::class, $builder); 113 | } 114 | 115 | public function test_valid_request_structure_for_tables() 116 | { 117 | $builder = app()->make(RequestBuilder::class); 118 | $table = 'u_customtable'; 119 | $q = 'column1==value;column2==value'; 120 | 121 | $builder->table($table) 122 | ->method('get') 123 | ->projection(['id', 'column1', 'column2']) 124 | ->pageSize(5) 125 | ->q($q) 126 | ->sort('string,null', true) 127 | ->buildRequestQuery(); 128 | 129 | $options = $this->accessor->get($builder, 'options')(); 130 | 131 | $this->assertEquals($table, $this->accessor->get($builder, 'table')()); 132 | $this->assertEquals("/ws/schema/table/{$table}", $this->accessor->get($builder, 'endpoint')()); 133 | $this->assertStringContainsString('projection=id,column1,column2', $options['query']); 134 | $this->assertStringContainsString('&', $options['query']); 135 | $this->assertStringContainsString('pagesize=5', $options['query']); 136 | $this->assertStringContainsString('sort=string,null', $options['query']); 137 | $this->assertStringContainsString('sortdescending=true', $options['query']); 138 | } 139 | 140 | public function test_valid_request_structure_for_named_queries() 141 | { 142 | $builder = app()->make(RequestBuilder::class); 143 | $query = 'com.organization.plugin_name.entity.query_name'; 144 | $expectedJson = [ 145 | 'string' => 'value1', 146 | 'number' => '1', 147 | 'boolean' => '0', 148 | 'null' => '', 149 | ]; 150 | 151 | $builder->namedQuery($query) 152 | ->with([ 153 | 'string' => 'value1', 154 | 'number' => 1, 155 | 'boolean' => false, 156 | 'null' => null, 157 | ]) 158 | ->pageSize(10) 159 | ->page(2) 160 | ->filter('othernumber=lt=100') 161 | ->includeCount() 162 | ->buildRequestJson() 163 | ->buildRequestQuery(); 164 | 165 | $options = $this->accessor->get($builder, 'options')(); 166 | 167 | $this->assertEquals("/ws/schema/query/{$query}", $this->accessor->get($builder, 'endpoint')()); 168 | $this->assertEquals(RequestBuilder::POST, $this->accessor->get($builder, 'method')()); 169 | $this->assertEquals($expectedJson, $options['json']); 170 | $this->assertStringContainsString('page=2', $options['query']); 171 | $this->assertStringContainsString('$q=othernumber=lt=100', $options['query']); 172 | $this->assertStringContainsString('count=true', $options['query']); 173 | $this->assertStringNotContainsString('projection', $options['query']); 174 | } 175 | 176 | public function test_can_set_auth_token_and_cache_it() 177 | { 178 | $handler = new MockHandler([ 179 | $this->getAuthResponse(), 180 | ]); 181 | 182 | $request = $this->getRequestMock($handler, ['getClient']); 183 | 184 | $request->authenticate(); 185 | 186 | $this->assertEquals(self::AUTH_TOKEN, $this->accessor->get($request, 'authToken')()); 187 | $this->assertEquals(self::AUTH_TOKEN, Cache::get(static::CACHE_KEY)); 188 | } 189 | 190 | public function test_performs_auth_request_before_query() 191 | { 192 | $handler = new MockHandler([ 193 | new Response(200) 194 | ]); 195 | 196 | $request = $this->getRequestMock($handler); 197 | 198 | $request->expects($this->once()) 199 | ->method('authenticate') 200 | ->willReturnSelf(); 201 | 202 | $builder = $this->getRequestBuilderMock(null, $request); 203 | 204 | $builder->table('u_table')->id(1)->get(); 205 | } 206 | 207 | public function test_performs_auth_request_after_token_expires() 208 | { 209 | $handler = new MockHandler([ 210 | new Response(401, ['WWW-Authenticate' => 'Bearer error="invalid_token",realm="PowerSchool",error_description="The access token has expired"']), 211 | $this->getAuthResponse(), 212 | new Response(200), 213 | ]); 214 | 215 | $request = $this->getRequestMock($handler); 216 | 217 | $request->expects($this->exactly(3)) 218 | ->method('authenticate') 219 | ->willReturnSelf(); 220 | 221 | $builder = $this->getRequestBuilderMock(null, $request); 222 | $builder->table('u_table')->id(1)->get(); 223 | } 224 | 225 | public function test_throws_exception_on_errors() 226 | { 227 | $this->expectException(GuzzleException::class); 228 | 229 | $handler = new MockHandler([ 230 | new Response(500), 231 | ]); 232 | 233 | $request = $this->getRequestMock($handler); 234 | 235 | $request->expects($this->any()) 236 | ->method('authenticate') 237 | ->willReturnSelf(); 238 | 239 | $builder = $this->getRequestBuilderMock(null, $request); 240 | $builder->table('u_table')->id(1)->get(); 241 | } 242 | 243 | public function test_response_can_infer_record_key_data() 244 | { 245 | $data = [ 246 | "name" => "users", 247 | 'record' => [ 248 | [ 249 | "_name" => "users", 250 | "last_name" => "x", 251 | "teachernumber" => "x", 252 | "dcid" => "x", 253 | "schoolid" => "x", 254 | ], 255 | [ 256 | "_name" => "users", 257 | "last_name" => "x", 258 | "dcid" => "x", 259 | "schoolid" => "x", 260 | ], 261 | ], 262 | "@extensions" => "erpfields,userscorefields" 263 | ]; 264 | 265 | $response = new ApiResponse($data, 'record'); 266 | 267 | $this->assertCount(2, $response); 268 | $this->assertEquals(['erpfields', 'userscorefields'], $response->extensions); 269 | 270 | foreach ($response as $item) { 271 | $this->assertArrayHasKey('dcid', $item); 272 | } 273 | } 274 | 275 | public function test_response_can_infer_endpoint_key_data() 276 | { 277 | $data = [ 278 | 'students' => [ 279 | 'student' => [ 280 | [ 281 | "id" => "1", 282 | "local_id" => "1", 283 | "student_username" => "x", 284 | ], 285 | [ 286 | "id" => "2", 287 | "local_id" => "2", 288 | "student_username" => "x", 289 | ], 290 | ], 291 | "@expansions" => "demographics, addresses, alerts, phones, school_enrollment, ethnicity_race, contact, contact_info, initial_enrollment, schedule_setup, fees, lunch, global_id", 292 | "@extensions" => "u_prntrsvlunchwyis,u_docbox_extension,u_tienet_alerts,c_studentlocator,u_mba_report_cards,s_stu_crosslea_x,u_studentsuserfields,u_isc_passport_students,u_admissions_students_extension,u_powermenu_plus_extension,s_stu_crdc_x,s_stu_x,activities,u_re_enrollment_extension,u_students_extension,s_stu_ncea_x,s_stu_edfi_x,studentcorefields", 293 | ], 294 | ]; 295 | 296 | $response = new ApiResponse($data, 'student'); 297 | 298 | $this->assertCount(2, $response); 299 | $this->assertNotEmpty($response->extensions); 300 | $this->assertNotEmpty($response->expansions); 301 | 302 | foreach ($response as $item) { 303 | $this->assertArrayHasKey('local_id', $item); 304 | } 305 | } 306 | 307 | public function test_response_can_infer_single_endpoint_key_data() 308 | { 309 | $data = [ 310 | "school" => [ 311 | "@expansions" => "one, two, three", 312 | "@extensions" => "one,two,three", 313 | "id" => 10, 314 | "name" => "My school name", 315 | "school_number" => 100, 316 | "low_grade" => 0, 317 | "high_grade" => 12, 318 | "alternate_school_number" => 0, 319 | "addresses" => [], 320 | "phones" => [], 321 | "principal" => [], 322 | ], 323 | ]; 324 | 325 | $response = new ApiResponse($data, '10'); 326 | 327 | $this->assertCount(9, array_keys($response->toArray())); 328 | $this->assertCount(3, $response->extensions); 329 | $this->assertCount(3, $response->expansions); 330 | 331 | $this->assertEquals($data['school']['name'], $response['name']); 332 | $this->assertEquals($data['school']['school_number'], $response['school_number']); 333 | $this->assertEquals($data['school']['high_grade'], $response['high_grade']); 334 | } 335 | 336 | public function test_response_can_infer_students_contact_response() 337 | { 338 | $data = [ 339 | [ 340 | "contactId" => 1, 341 | "firstName" => 'John', 342 | "middleName" => null, 343 | "lastName" => 'Doe', 344 | "prefix" => null, 345 | "suffix" => null, 346 | "gender" => null, 347 | "employer" => null, 348 | "stateContactNumber" => null, 349 | "contactNumber" => null, 350 | "stateExcludeFromReporting" => false, 351 | "active" => true, 352 | "emails" => [], 353 | "phones" => [], 354 | "language" => null, 355 | "contactAccount" => [], 356 | "addresses" => [], 357 | "contactStudents" => [], 358 | "mergedIds" => [], 359 | "mergeAccountId" => null, 360 | "@extensions" => "personcorefields", 361 | ], 362 | [ 363 | "contactId" => 2, 364 | "firstName" => 'Jane', 365 | "middleName" => null, 366 | "lastName" => 'Doe', 367 | "prefix" => null, 368 | "suffix" => null, 369 | "gender" => null, 370 | "employer" => null, 371 | "stateContactNumber" => null, 372 | "contactNumber" => null, 373 | "stateExcludeFromReporting" => false, 374 | "active" => true, 375 | "emails" => [], 376 | "phones" => [], 377 | "language" => null, 378 | "contactAccount" => null, 379 | "addresses" => [], 380 | "contactStudents" => [], 381 | "mergedIds" => [], 382 | "mergeAccountId" => null, 383 | "@extensions" => "personcorefields", 384 | ] 385 | ]; 386 | 387 | $response = new ApiResponse($data, 123); 388 | 389 | $this->assertCount(2, array_keys($response->toArray())); 390 | $this->assertCount(0, $response->extensions); 391 | $this->assertCount(0, $response->expansions); 392 | 393 | foreach ($response as $item) { 394 | $this->assertArrayHasKey('contactId', $item); 395 | } 396 | } 397 | 398 | public function test_response_can_infer_contacts_single_student_response() 399 | { 400 | $data = [ 401 | [ 402 | "deleted" => false, 403 | "studentContactId" => 1, 404 | "sequence" => 1, 405 | "dcid" => 1, 406 | "studentNumber" => "28014473", 407 | "firstName" => "John", 408 | "middleName" => null, 409 | "lastName" => "Smith", 410 | "schoolAbbr" => "School", 411 | "schoolNumber" => 1, 412 | "originalContactType" => null, 413 | "autosendHowOften" => null, 414 | "emailSummary" => null, 415 | "assignmentDetails" => null, 416 | "attendanceDetails" => null, 417 | "schoolAnnouncements" => null, 418 | "balanceAlert" => null, 419 | "notificationEmails" => null, 420 | "userHasAccess" => null, 421 | "contactStudentAssocExtension" => [], 422 | "sendNow" => false, 423 | "canAccessData" => true, 424 | "guardianId" => 1, 425 | 'restrictions' => [], 426 | 'studentDetails' => [], 427 | ] 428 | ]; 429 | 430 | $response = new ApiResponse($data, 'students'); 431 | 432 | $this->assertCount(1, array_keys($response->toArray())); 433 | $this->assertCount(0, $response->extensions); 434 | $this->assertCount(0, $response->expansions); 435 | 436 | foreach ($response as $item) { 437 | $this->assertArrayHasKey('studentContactId', $item); 438 | } 439 | } 440 | 441 | public function test_response_can_infer_pq_key_data() 442 | { 443 | $data = [ 444 | "record" => [ 445 | ['id' => 1], 446 | ['id' => 2], 447 | ['id' => 3], 448 | ], 449 | "@extensions" => "", 450 | ]; 451 | 452 | $response = new ApiResponse($data, 'record'); 453 | 454 | $this->assertCount(3, $response); 455 | $this->assertEmpty($response->extensions); 456 | $this->assertEmpty($response->expansions); 457 | 458 | foreach ($response as $item) { 459 | $this->assertArrayHasKey('id', $item); 460 | } 461 | } 462 | 463 | public function test_response_can_infer_results_response() 464 | { 465 | $data = [ 466 | 'results' => [ 467 | 'insert_count' => 1, 468 | 'update_count' => 0, 469 | 'delete_count' => 0, 470 | 'result' => [ 471 | 'client_uid' => 1, 472 | 'status' => 'SUCCESS', 473 | 'action' => 'INSERT', 474 | 'success_message' => [ 475 | 'id' => 100, 476 | 'ref' => 'https://powerschool.example.com/ws/v1/student/100', 477 | ], 478 | ] 479 | ] 480 | ]; 481 | 482 | $response = new ApiResponse($data, 'student'); 483 | $this->assertTrue(isset($response['insert_count'])); 484 | $this->assertEquals(100, $response['result']['success_message']['id']); 485 | } 486 | 487 | public function test_response_can_infer_contacts_response() 488 | { 489 | $data = [ 490 | "contactId" => 1, 491 | "firstName" => "John", 492 | "middleName" => null, 493 | "lastName" => "Doe", 494 | "prefix" => null, 495 | "suffix" => null, 496 | "gender" => null, 497 | "employer" => null, 498 | "stateContactNumber" => null, 499 | "contactNumber" => null, 500 | "stateExcludeFromReporting" => false, 501 | "active" => true, 502 | "emails" => [], 503 | "phones" => [], 504 | "language" => null, 505 | "contactAccount" => [], 506 | "addresses" => [], 507 | "contactStudents" => [], 508 | "mergedIds" => [], 509 | "mergeAccountId" => null, 510 | "@extensions" => "integration_person,personcorefields", 511 | ]; 512 | 513 | $response = new ApiResponse($data, 1); 514 | $this->assertTrue(isset($response['firstName'])); 515 | $this->assertEquals('John', $response['firstName']); 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /src/RequestBuilder.php: -------------------------------------------------------------------------------- 1 | request = new Request($serverAddress, $clientId, $clientSecret, $cacheKey); 32 | } 33 | 34 | public function getRequest(): Request 35 | { 36 | return $this->request; 37 | } 38 | 39 | /** 40 | * Cleans all the variables for the next request 41 | */ 42 | public function freshen(): static 43 | { 44 | $this->endpoint = null; 45 | $this->method = null; 46 | $this->options = []; 47 | $this->data = null; 48 | $this->table = null; 49 | $this->queryString = []; 50 | $this->id = null; 51 | $this->includeProjection = false; 52 | $this->asJsonResponse = false; 53 | unset($this->paginator); 54 | $this->pageKey = 'record'; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Sets the table for a request against a custom table 61 | */ 62 | public function setTable(string $table): static 63 | { 64 | $this->table = Str::afterLast($table, '/'); 65 | $this->endpoint = Str::startsWith($table, '/') 66 | ? $table 67 | : '/ws/schema/table/' . $table; 68 | $this->includeProjection = true; 69 | $this->pageKey = 'record'; 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * @see setTable 76 | */ 77 | public function table(string $table): static 78 | { 79 | return $this->setTable($table); 80 | } 81 | 82 | /** 83 | * @see setTable 84 | */ 85 | public function forTable(string $table): static 86 | { 87 | return $this->setTable($table); 88 | } 89 | 90 | /** 91 | * @see setTable 92 | */ 93 | public function againstTable(string $table): static 94 | { 95 | return $this->setTable($table); 96 | } 97 | 98 | /** 99 | * Sets the id of the resource we're interacting with 100 | */ 101 | public function setId(string|int $id): static 102 | { 103 | $this->endpoint .= '/' . $id; 104 | $this->id = $id; 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * @see setId 111 | */ 112 | public function id(string|int $id): static 113 | { 114 | return $this->setId($id); 115 | } 116 | 117 | /** 118 | * @see setId 119 | */ 120 | public function forId(string|int $id): static 121 | { 122 | return $this->setId($id); 123 | } 124 | 125 | /** 126 | * Configures the request to be a core resource with optional method and data that 127 | * will send the request automatically. 128 | */ 129 | public function resource(string $endpoint, ?string $method = null, array $data = []): null|Response|static 130 | { 131 | $this->endpoint = $endpoint; 132 | $this->includeProjection = false; 133 | 134 | if (!is_null($method)) { 135 | $this->method = $method; 136 | } 137 | 138 | if (!empty($data)) { 139 | $this->setData($data); 140 | } 141 | 142 | // If the method and data are set, automatically send the request 143 | if (!is_null($this->method) && !empty($this->data)) { 144 | return $this->send(); 145 | } 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Does not force a projection parameter for GET requests 152 | */ 153 | public function excludeProjection(): static 154 | { 155 | $this->includeProjection = false; 156 | 157 | return $this; 158 | } 159 | 160 | /** 161 | * @see excludeProjection 162 | */ 163 | public function withoutProjection(): static 164 | { 165 | return $this->excludeProjection(); 166 | } 167 | 168 | /** 169 | * Sets the endpoint for the request 170 | */ 171 | public function setEndpoint(string $endpoint): static 172 | { 173 | $this->endpoint = $endpoint; 174 | $this->pageKey = Str::afterLast($endpoint, '/'); 175 | 176 | return $this->excludeProjection(); 177 | } 178 | 179 | /** 180 | * @see setEndpoint 181 | */ 182 | public function toEndpoint(string $endpoint): static 183 | { 184 | return $this->setEndpoint($endpoint); 185 | } 186 | 187 | /** 188 | * @see setEndpoint 189 | */ 190 | public function to(string $endpoint): static 191 | { 192 | return $this->setEndpoint($endpoint); 193 | } 194 | 195 | /** 196 | * @see setEndpoint 197 | */ 198 | public function endpoint(string $endpoint): static 199 | { 200 | return $this->setEndpoint($endpoint); 201 | } 202 | 203 | /** 204 | * Sets the endpoint to the named query 205 | * 206 | * @param string $query The named query name (com.organization.product.area.name) 207 | * @param array $data 208 | * @return RequestBuilder|mixed 209 | */ 210 | public function setNamedQuery(string $query, array $data = []) 211 | { 212 | $this->endpoint = Str::startsWith($query, '/') 213 | ? $query 214 | : '/ws/schema/query/' . $query; 215 | $this->pageKey = 'record'; 216 | 217 | // If there's data along with it, 218 | // it's shorthand for sending the request 219 | if (!empty($data)) { 220 | return $this->withData($data)->post(); 221 | } 222 | 223 | // By default, don't include the projection unless 224 | // it gets added later explicitly 225 | $this->includeProjection = false; 226 | 227 | return $this->setMethod(static::POST); 228 | } 229 | 230 | /** 231 | * Alias for setNamedQuery() 232 | * 233 | * @param string $query The named query name (com.organization.product.area.name) 234 | * @param array $data 235 | * @return mixed 236 | */ 237 | public function namedQuery(string $query, array $data = []) 238 | { 239 | return $this->setNamedQuery($query, $data); 240 | } 241 | 242 | /** 243 | * Alias for setNamedQuery() 244 | * 245 | * @param string $query The named query name (com.organization.product.area.name) 246 | * @param array $data 247 | * @return mixed 248 | */ 249 | public function powerQuery(string $query, array $data = []) 250 | { 251 | return $this->setNamedQuery($query, $data); 252 | } 253 | 254 | /** 255 | * Alias for setNamedQuery() 256 | * 257 | * @param string $query The named query name (com.organization.product.area.name) 258 | * @param array $data 259 | * @return mixed 260 | */ 261 | public function pq(string $query, array $data = []) 262 | { 263 | return $this->setNamedQuery($query, $data); 264 | } 265 | 266 | /** 267 | * Sets the data for the post/put/patch requests 268 | * Also performs basic sanitation for PS, such 269 | * as bool translation 270 | */ 271 | public function setData(array $data): static 272 | { 273 | $this->data = $this->castToValuesString($data); 274 | 275 | return $this; 276 | } 277 | 278 | /** 279 | * Alias for setData() 280 | */ 281 | public function withData(array $data): static 282 | { 283 | return $this->setData($data); 284 | } 285 | 286 | /** 287 | * Alias for setData() 288 | */ 289 | public function with(array $data): static 290 | { 291 | return $this->setData($data); 292 | } 293 | 294 | /** 295 | * Sets an item to be included in the post request 296 | */ 297 | public function setDataItem(string $key, $value): static 298 | { 299 | $this->data[$key] = $this->castToValuesString($value); 300 | 301 | return $this; 302 | } 303 | 304 | /** 305 | * Sets the query string for get requests 306 | */ 307 | public function withQueryString(string|array $queryString): static 308 | { 309 | if (is_array($queryString)) { 310 | $this->queryString = $queryString; 311 | } else { 312 | parse_str($queryString, $this->queryString); 313 | } 314 | 315 | return $this; 316 | } 317 | 318 | /** 319 | * Alias of withQueryString() 320 | */ 321 | public function query(string|array $queryString): static 322 | { 323 | return $this->withQueryString($queryString); 324 | } 325 | 326 | /** 327 | * Adds a variable to the query string 328 | */ 329 | public function addQueryVar(string $key, $value): static 330 | { 331 | $this->queryString[$key] = $value; 332 | 333 | return $this; 334 | } 335 | 336 | /** 337 | * Checks to see if a query variable has been set 338 | */ 339 | public function hasQueryVar(string $key): bool 340 | { 341 | return isset($this->queryString[$key]) && 342 | !empty($this->queryString[$key]); 343 | } 344 | 345 | /** 346 | * Syntactic sugar for the q query string var 347 | */ 348 | public function q(string $query): static 349 | { 350 | return $this->addQueryVar('q', $query); 351 | } 352 | 353 | /** 354 | * Sugar for q() 355 | */ 356 | public function queryExpression(string $expression): static 357 | { 358 | return $this->q($expression); 359 | } 360 | 361 | /** 362 | * Adds an ad-hoc filter expression, meant to be used for PowerQueries 363 | */ 364 | public function adHocFilter(string $expression): static 365 | { 366 | return $this->addQueryVar('$q', $expression); 367 | } 368 | 369 | /** 370 | * Sugar for adHocFilter() 371 | */ 372 | public function filter(string $expression): static 373 | { 374 | return $this->adHocFilter($expression); 375 | } 376 | 377 | /** 378 | * Syntactic sugar for the projection query string var 379 | */ 380 | public function projection(string|array $projection): static 381 | { 382 | if (is_array($projection)) { 383 | $projection = implode(',', $projection); 384 | } 385 | $this->includeProjection = true; 386 | 387 | return $this->addQueryVar('projection', $projection); 388 | } 389 | 390 | /** 391 | * Syntactic sugar for the `pagesize` query string var 392 | */ 393 | public function pageSize(int $pageSize): static 394 | { 395 | return $this->addQueryVar('pagesize', $pageSize); 396 | } 397 | 398 | /** 399 | * Sets the page query variable 400 | */ 401 | public function page(int $page): static 402 | { 403 | return $this->addQueryVar('page', $page); 404 | } 405 | 406 | /** 407 | * Sets the sorting columns and direction for the request 408 | */ 409 | public function sort(string|array $columns, bool $descending = false): static 410 | { 411 | $sort = is_array($columns) 412 | ? implode(',', $columns) 413 | : $columns; 414 | 415 | $this->addQueryVar('sort', $sort); 416 | $this->addQueryVar('sortdescending', $descending ? 'true' : 'false'); 417 | 418 | return $this; 419 | } 420 | 421 | /** 422 | * Adds an order query string variable 423 | */ 424 | public function adHocOrder(string $expression): static 425 | { 426 | return $this->addQueryVar('order', $expression); 427 | } 428 | 429 | /** 430 | * @see adHocOrder() 431 | */ 432 | public function order(string $expression): static 433 | { 434 | return $this->adHocOrder($expression); 435 | } 436 | 437 | /** 438 | * Adds the count query variable for PowerQueries 439 | */ 440 | public function includeCount(): static 441 | { 442 | return $this->addQueryVar('count', 'true'); 443 | } 444 | 445 | /** 446 | * Configures the data version for the PowerQuery 447 | */ 448 | public function dataVersion(int $version, string $applicationName): static 449 | { 450 | return $this->setDataItem('$dataversion', $version) 451 | ->setDataItem('$dataversion_applicationname', $applicationName); 452 | } 453 | 454 | /** 455 | * Alias of dataVersion() 456 | */ 457 | public function withDataVersion(int $version, string $applicationName): static 458 | { 459 | return $this->dataVersion($version, $applicationName); 460 | } 461 | 462 | /** 463 | * Adds `expansions` query variable 464 | */ 465 | public function expansions(string|array $expansions): static 466 | { 467 | $expansions = is_array($expansions) 468 | ? implode(',', $expansions) 469 | : $expansions; 470 | 471 | $this->addQueryVar('expansions', $expansions); 472 | 473 | return $this; 474 | } 475 | 476 | /** 477 | * Alias of expansions() 478 | */ 479 | public function withExpansions(string|array $expansions): static 480 | { 481 | return $this->expansions($expansions); 482 | } 483 | 484 | /** 485 | * Alias of expansions() 486 | */ 487 | public function withExpansion(string $expansion): static 488 | { 489 | return $this->expansions($expansion); 490 | } 491 | 492 | /** 493 | * Adds `expansions` query variable 494 | */ 495 | public function extensions(string|array $extensions): static 496 | { 497 | $extensions = is_array($extensions) 498 | ? implode(',', $extensions) 499 | : $extensions; 500 | 501 | $this->addQueryVar('extensions', $extensions); 502 | 503 | return $this; 504 | } 505 | 506 | /** 507 | * Alias of expansions() 508 | */ 509 | public function withExtensions(string|array $extensions): static 510 | { 511 | return $this->extensions($extensions); 512 | } 513 | 514 | /** 515 | * Alias of extensions() 516 | */ 517 | public function withExtension(string $extension): static 518 | { 519 | return $this->extensions($extension); 520 | } 521 | 522 | /** 523 | * Gets the data changes based on the data version subscription 524 | */ 525 | public function getDataSubscriptionChanges(string $applicationName, int $version): Response 526 | { 527 | return $this->endpoint("/ws/dataversion/{$applicationName}/{$version}") 528 | ->get(); 529 | } 530 | 531 | /** 532 | * Sends a count request to the table api 533 | */ 534 | public function count(): Response 535 | { 536 | $this->endpoint .= '/count'; 537 | $this->includeProjection = false; 538 | 539 | return $this->get(); 540 | } 541 | 542 | /** 543 | * Sets a flag to return as a decoded json rather than an Illuminate\Response 544 | */ 545 | public function raw(): static 546 | { 547 | $this->asJsonResponse = false; 548 | 549 | return $this; 550 | } 551 | 552 | /** 553 | * Sets the flag to return a response 554 | */ 555 | public function asJsonResponse(): static 556 | { 557 | $this->asJsonResponse = true; 558 | 559 | return $this; 560 | } 561 | 562 | /** 563 | * Casts all the values recursively as a string 564 | */ 565 | protected function castToValuesString(array $data): array 566 | { 567 | foreach ($data as $key => $value) { 568 | // Recursively set the nested array values 569 | if (is_array($value)) { 570 | $data[$key] = $this->castToValuesString($value); 571 | continue; 572 | } 573 | 574 | // If it's null set the value to an empty string 575 | if (is_null($value)) { 576 | $value = ''; 577 | } 578 | 579 | // If the type is a bool, set it to the 580 | // integer type that PS uses, 1 or 0 581 | if (is_bool($value)) { 582 | $value = $value ? '1' : '0'; 583 | } 584 | 585 | // Cast everything as a string, otherwise PS 586 | // with throw a typecast error or something 587 | $data[$key] = (string) $value; 588 | } 589 | 590 | return $data; 591 | } 592 | 593 | /** 594 | * Builds the dumb request structure for PowerSchool 595 | */ 596 | public function buildRequestJson(): static 597 | { 598 | if ($this->method === static::GET || $this->method === 'delete') { 599 | return $this; 600 | } 601 | 602 | // Reset the json object from previous requests 603 | $this->options['json'] = []; 604 | 605 | if ($this->table) { 606 | $this->options['json']['tables'] = [$this->table => $this->data]; 607 | } 608 | 609 | if ($this->id) { 610 | $this->options['json']['id'] = $this->id; 611 | $this->options['json']['name'] = $this->table; 612 | } 613 | 614 | if ($this->data && !$this->table) { 615 | $this->options['json'] = $this->data; 616 | } 617 | 618 | // Remove the json option if there is nothing there 619 | if (empty($this->options['json'])) { 620 | unset($this->options['json']); 621 | } 622 | 623 | return $this; 624 | } 625 | 626 | /** 627 | * Builds the query string for the request 628 | */ 629 | public function buildRequestQuery(): static 630 | { 631 | // Build the query by hand 632 | if ($this->method !== static::GET && $this->method !== static::POST) { 633 | return $this; 634 | } 635 | 636 | $this->options['query'] = ''; 637 | $qs = []; 638 | 639 | // Build the query string 640 | foreach ($this->queryString as $var => $val) { 641 | $qs[] = $var . '=' . $val; 642 | } 643 | 644 | // Get requests are required to have a projection parameter 645 | if ( 646 | !$this->hasQueryVar('projection') && 647 | $this->includeProjection 648 | ) { 649 | $qs[] = 'projection=*'; 650 | } 651 | 652 | if (!empty($qs)) { 653 | $this->options['query'] = implode('&', $qs); 654 | } 655 | 656 | return $this; 657 | } 658 | 659 | /** 660 | * Sets the request method 661 | */ 662 | public function setMethod(string $method): static 663 | { 664 | $this->method = $method; 665 | 666 | return $this; 667 | } 668 | 669 | /** 670 | * @deprecated 671 | */ 672 | public function method(string $method): static 673 | { 674 | trigger_error('method() is deprecated, use setMethod() or usingMethod() instead.', E_USER_DEPRECATED); 675 | 676 | return $this->setMethod($method); 677 | } 678 | 679 | /** 680 | * @see setMethod() 681 | */ 682 | public function usingMethod(string $method): static 683 | { 684 | return $this->setMethod($method); 685 | } 686 | 687 | /** 688 | * Sets method to get, sugar around setMethod(), then sends the request 689 | */ 690 | public function get(?string $endpoint = null): Response 691 | { 692 | if ($endpoint) { 693 | $this->setEndpoint($endpoint); 694 | } 695 | 696 | return $this->setMethod(static::GET)->send(); 697 | } 698 | 699 | /** 700 | * Sets method to post, sugar around setMethod(), then sends the request 701 | */ 702 | public function post(): Response 703 | { 704 | return $this->setMethod(static::POST)->send(); 705 | } 706 | 707 | /** 708 | * Sets method to put, sugar around setMethod(), then sends the request 709 | */ 710 | public function put(): Response 711 | { 712 | return $this->setMethod(static::PUT)->send(); 713 | } 714 | 715 | /** 716 | * Sets method to patch, sugar around setMethod(), then sends the request 717 | */ 718 | public function patch(): Response 719 | { 720 | return $this->setMethod(static::PATCH)->send(); 721 | } 722 | 723 | /** 724 | * Sets method to delete, sugar around setMethod(), then sends the request 725 | */ 726 | public function delete(): Response 727 | { 728 | return $this->setMethod(static::DELETE)->send(); 729 | } 730 | 731 | /** 732 | * Sends the request to PowerSchool 733 | */ 734 | public function send(bool $reset = true): Response|JsonResponse|null 735 | { 736 | $this->buildRequestJson() 737 | ->buildRequestQuery(); 738 | 739 | $responseData = $this->getRequest() 740 | ->makeRequest( 741 | $this->method, 742 | $this->endpoint, 743 | $this->options, 744 | $this->asJsonResponse 745 | ); 746 | 747 | if ($responseData instanceof JsonResponse) { 748 | return $responseData; 749 | } 750 | 751 | $response = new Response($responseData, $this->pageKey); 752 | 753 | if ($reset) { 754 | $this->freshen(); 755 | } 756 | 757 | return $response; 758 | } 759 | 760 | /** 761 | * This will return a chunk of data from PS 762 | */ 763 | public function paginate(int $pageSize = 100): ?Response 764 | { 765 | if (!isset($this->paginator)) { 766 | $this->paginator = new Paginator($this, $pageSize); 767 | } 768 | 769 | $results = $this->paginator->page(); 770 | 771 | if ($results === null) { 772 | $this->freshen(); 773 | } 774 | 775 | return $results; 776 | } 777 | } 778 | --------------------------------------------------------------------------------