├── 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 |
--------------------------------------------------------------------------------