Skip to content

Commit 1ae3cf2

Browse files
committed
fixes
- fix API skipping records which don't have all the fields: it fills the empty fields with NULL - load configuration from file instead of inline - add CSV debuging which is logged to error log; - add extra endpoint to debug CSV line - add some tests for automated testing
1 parent bc251e9 commit 1ae3cf2

File tree

9 files changed

+734
-52
lines changed

9 files changed

+734
-52
lines changed

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,17 @@ DELETE /api/csv/{filename}
183183
Authorization: Bearer <token>
184184
```
185185

186+
#### Download File
187+
188+
```http
189+
GET /api/csv/{filename}/download
190+
Authorization: Bearer <token>
191+
```
192+
193+
This endpoint will download the CSV file directly. The file will be served with appropriate headers for download.
194+
195+
**Note:** This endpoint returns the raw CSV file content, not JSON. The browser will automatically download the file.
196+
186197
### Record Operations
187198

188199
#### Get All Records
@@ -252,21 +263,37 @@ Content-Type: application/vnd.api+json
252263
#### Update Record
253264

254265
```http
255-
PUT /api/csv/{filename}/{id}
266+
PATCH /api/csv/{filename}/{id}
256267
Authorization: Bearer <token>
257268
Content-Type: application/vnd.api+json
258269
259270
{
260271
"data": {
272+
"attributes": {
273+
"name": "John Updated"
274+
}
275+
}
276+
}
277+
```
278+
279+
Response:
280+
281+
```json
282+
{
283+
"data": {
284+
"type": "example",
285+
"id": "0",
261286
"attributes": {
262287
"id": "1",
263288
"name": "John Updated",
264-
"email": "john.updated@example.com"
289+
"email": "john@example.com"
265290
}
266291
}
267292
}
268293
```
269294

295+
**Note:** The PATCH method supports partial updates. You only need to include the fields you want to update. The response will contain the complete updated record.
296+
270297
#### Delete Record
271298

272299
```http

api.php

Lines changed: 191 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
<?php
22

3-
// JWT Configuration
4-
define('JWT_SECRET', 'your-secret-key-change-this-in-production'); // TODO: change to a secure secret
5-
define('JWT_ALGORITHM', 'HS256');
6-
define('JWT_EXPIRY', 3600); // 1 hour
7-
define('DATA_DIR', __DIR__ . '/data'); // TODO: change to your data directory
3+
require "config.php";
84

9-
// User credentials (in production, use a database)
10-
$validUsers = [
11-
'admin' => password_hash('secret123', PASSWORD_DEFAULT)
12-
];
5+
// Debug configuration
6+
define('CSV_DEBUG_ENABLED', true); // Set to false to disable CSV debug logging
137

148
// JWT Helper Functions
159
function base64url_encode($data) {
@@ -79,8 +73,8 @@ function sanitizeInput($input) {
7973
if (!is_string($input)) {
8074
return $input;
8175
}
82-
// Remove HTML tags and encode special characters
83-
return htmlspecialchars(strip_tags($input), ENT_QUOTES, 'UTF-8');
76+
// Remove HTML tags but don't encode special characters
77+
return strip_tags($input);
8478
}
8579

8680
function sanitizeFilename($filename) {
@@ -177,35 +171,101 @@ public function __construct(string $filePath) {
177171

178172
private function loadData(): void {
179173
if (!file_exists($this->filePath)) {
174+
if (CSV_DEBUG_ENABLED) {
175+
error_log("CSV Debug: File not found: {$this->filePath}");
176+
}
180177
throw new RuntimeException("CSV file not found: {$this->filePath}");
181178
}
182179

180+
if (CSV_DEBUG_ENABLED) {
181+
error_log("CSV Debug: Opening file for reading: {$this->filePath}");
182+
}
183+
183184
$file = fopen($this->filePath, 'r');
184185
if ($file === false) {
186+
if (CSV_DEBUG_ENABLED) {
187+
error_log("CSV Debug: Failed to open file: {$this->filePath}");
188+
}
185189
throw new RuntimeException("Could not open file: {$this->filePath}");
186190
}
187191

188192
// Read headers
189193
$csvFile = fgetcsv($file);
190194
if(!$csvFile) {
195+
if (CSV_DEBUG_ENABLED) {
196+
error_log("CSV Debug: No headers found in file: {$this->filePath}");
197+
}
191198
fclose($file);
192199
throw new RuntimeException("Invalid CSV file: No headers found");
193200
}
194201
$this->headers = array_map('sanitizeInput', $csvFile);
195202
if ($this->headers === false) {
203+
if (CSV_DEBUG_ENABLED) {
204+
error_log("CSV Debug: Failed to sanitize headers in file: {$this->filePath}");
205+
}
196206
fclose($file);
197207
throw new RuntimeException("Invalid CSV file: No headers found");
198208
}
199209

210+
// Debug: Log headers
211+
if (CSV_DEBUG_ENABLED) {
212+
error_log("CSV Debug: Headers loaded for file {$this->filePath}: " . implode(', ', $this->headers));
213+
}
214+
200215
// Read data
201216
$this->data = [];
217+
$lineNumber = 1; // Start at 1 since we already read the header
218+
$validLines = 0;
219+
202220
while (($row = fgetcsv($file)) !== false) {
203-
if (count($row) === count($this->headers)) {
204-
$this->data[] = array_combine($this->headers, array_map('sanitizeInput', $row));
221+
$lineNumber++;
222+
223+
// Debug: Log each line being read
224+
if (CSV_DEBUG_ENABLED) {
225+
error_log("CSV Debug: Reading line {$lineNumber} from {$this->filePath}: " . implode(', ', $row));
226+
}
227+
228+
// Handle column count mismatches by padding or truncating
229+
if (count($row) !== count($this->headers)) {
230+
if (CSV_DEBUG_ENABLED) {
231+
error_log("CSV Debug: Column count mismatch on line {$lineNumber}. Expected: " . count($this->headers) . ", Got: " . count($row));
232+
}
233+
234+
if (count($row) < count($this->headers)) {
235+
// Fill missing columns with null
236+
$missingColumns = count($this->headers) - count($row);
237+
$row = array_merge($row, array_fill(0, $missingColumns, null));
238+
239+
if (CSV_DEBUG_ENABLED) {
240+
error_log("CSV Debug: Filled {$missingColumns} missing columns with null on line {$lineNumber}");
241+
}
242+
} else {
243+
// Truncate extra columns
244+
$extraColumns = count($row) - count($this->headers);
245+
$row = array_slice($row, 0, count($this->headers));
246+
247+
if (CSV_DEBUG_ENABLED) {
248+
error_log("CSV Debug: Truncated {$extraColumns} extra columns on line {$lineNumber}");
249+
}
250+
}
251+
}
252+
253+
// Now the row should have the correct number of columns
254+
$this->data[] = array_combine($this->headers, array_map('sanitizeInput', $row));
255+
$validLines++;
256+
257+
// Debug: Log successful line processing
258+
if (CSV_DEBUG_ENABLED) {
259+
error_log("CSV Debug: Successfully processed line {$lineNumber} - Record added");
205260
}
206261
}
207262

208263
fclose($file);
264+
265+
// Debug: Log summary
266+
if (CSV_DEBUG_ENABLED) {
267+
error_log("CSV Debug: File {$this->filePath} loaded successfully. Total lines read: {$lineNumber}, Valid records: {$validLines}");
268+
}
209269
}
210270

211271
private function saveData(): void {
@@ -233,7 +293,7 @@ private function formatResourceObject(array $row, int $index): array {
233293
];
234294
}
235295

236-
public function getAll(int $offset = 0, int $perPage = 10): array {
296+
public function getAll(int $offset = 0, int $perPage = 50): array {
237297
$resources = [];
238298
foreach ($this->data as $index => $row) {
239299
$resources[] = $this->formatResourceObject($row, $index);
@@ -254,6 +314,20 @@ public function getAll(int $offset = 0, int $perPage = 10): array {
254314
];
255315
}
256316

317+
public function getAllRecords(): array {
318+
$resources = [];
319+
foreach ($this->data as $index => $row) {
320+
$resources[] = $this->formatResourceObject($row, $index);
321+
}
322+
323+
return [
324+
'data' => $resources,
325+
'meta' => [
326+
'totalRecords' => count($resources)
327+
]
328+
];
329+
}
330+
257331
public function getById(int $id): ?array {
258332
if (!isset($this->data[$id])) {
259333
return null;
@@ -263,7 +337,7 @@ public function getById(int $id): ?array {
263337
];
264338
}
265339

266-
public function search(array $criteria, bool $exactMatch = false, int $offset = 0, int $perPage = 10): array {
340+
public function search(array $criteria, bool $exactMatch = false, int $offset = 0, int $perPage = 50): array {
267341
// Sanitize search criteria
268342
$criteria = sanitizeInput($criteria);
269343

@@ -339,14 +413,15 @@ public function update(int $id, array $attributes): bool {
339413
// Sanitize input attributes
340414
$attributes = sanitizeInput($attributes);
341415

342-
// Validate that all required headers are present
343-
foreach ($this->headers as $header) {
344-
if (!isset($attributes[$header])) {
345-
throw new InvalidArgumentException("Missing required field: {$header}");
416+
// Validate that all provided attributes correspond to valid headers
417+
foreach ($attributes as $key => $value) {
418+
if (!in_array($key, $this->headers)) {
419+
throw new InvalidArgumentException("Invalid field: {$key}");
346420
}
347421
}
348422

349-
$this->data[$id] = $attributes;
423+
// Merge provided attributes with existing data (partial update)
424+
$this->data[$id] = array_merge($this->data[$id], $attributes);
350425
$this->saveData();
351426
return true;
352427
}
@@ -372,6 +447,40 @@ public function getHeaders(): array {
372447
]
373448
];
374449
}
450+
451+
public function debugLine(int $lineNumber): array {
452+
if (!file_exists($this->filePath)) {
453+
throw new RuntimeException("CSV file not found: {$this->filePath}");
454+
}
455+
456+
$file = fopen($this->filePath, 'r');
457+
if ($file === false) {
458+
throw new RuntimeException("Could not open file: {$this->filePath}");
459+
}
460+
461+
$currentLine = 0;
462+
$targetLine = $lineNumber;
463+
464+
while (($row = fgetcsv($file)) !== false) {
465+
$currentLine++;
466+
if ($currentLine === $targetLine) {
467+
fclose($file);
468+
return [
469+
'line_number' => $lineNumber,
470+
'raw_content' => $row,
471+
'column_count' => count($row),
472+
'expected_columns' => count($this->headers),
473+
'headers' => $this->headers,
474+
'is_valid' => count($row) === count($this->headers),
475+
'missing_columns' => count($row) < count($this->headers) ? array_slice($this->headers, count($row)) : [],
476+
'extra_columns' => count($row) > count($this->headers) ? array_slice($row, count($this->headers)) : []
477+
];
478+
}
479+
}
480+
481+
fclose($file);
482+
throw new RuntimeException("Line {$lineNumber} not found in file");
483+
}
375484
}
376485

377486

@@ -445,6 +554,36 @@ public function getHeaders(): array {
445554
]));
446555
}
447556

557+
// Handle download endpoints
558+
if (count($parts) >= 4 && $parts[0] === 'api' && $parts[1] === 'csv' && $parts[3] === 'download') {
559+
$filename = $parts[2];
560+
$filePath = DATA_DIR . '/' . $filename;
561+
562+
if (!file_exists($filePath)) {
563+
http_response_code(404);
564+
die(json_encode([
565+
'errors' => [
566+
[
567+
'status' => '404',
568+
'title' => 'Not Found',
569+
'detail' => 'The requested file was not found'
570+
]
571+
]
572+
]));
573+
}
574+
575+
// Set headers for file download
576+
header('Content-Type: text/csv');
577+
header('Content-Disposition: attachment; filename="' . $filename . '"');
578+
header('Content-Length: ' . filesize($filePath));
579+
header('Cache-Control: no-cache, must-revalidate');
580+
header('Expires: Sat, 26 Jul 1997 05:00:00 GMT');
581+
582+
// Output the file content
583+
readfile($filePath);
584+
exit;
585+
}
586+
448587
// Validate path structure for CSV endpoints
449588
if (count($parts) < 2 || $parts[0] !== 'api' || $parts[1] !== 'csv') {
450589
http_response_code(404);
@@ -645,6 +784,33 @@ public function getHeaders(): array {
645784
case 'GET':
646785
if (isset($parts[3]) && $parts[3] === 'structure') {
647786
die(json_encode($csvHandler->getHeaders()));
787+
} else if (isset($parts[3]) && $parts[3] === 'all') {
788+
// Get all records without pagination
789+
die(json_encode($csvHandler->getAllRecords()));
790+
} else if (isset($parts[3]) && $parts[3] === 'debug' && isset($parts[4])) {
791+
// Debug specific line
792+
$lineNumber = (int)$parts[4];
793+
try {
794+
$debugInfo = $csvHandler->debugLine($lineNumber);
795+
die(json_encode([
796+
'data' => [
797+
'type' => 'debug_info',
798+
'id' => 'line_' . $lineNumber,
799+
'attributes' => $debugInfo
800+
]
801+
]));
802+
} catch (Exception $e) {
803+
http_response_code(400);
804+
die(json_encode([
805+
'errors' => [
806+
[
807+
'status' => '400',
808+
'title' => 'Bad Request',
809+
'detail' => $e->getMessage()
810+
]
811+
]
812+
]));
813+
}
648814
} else if (isset($parts[3]) && $parts[3] === 'search') {
649815

650816
} else if (isset($parts[3])) {
@@ -670,7 +836,7 @@ public function getHeaders(): array {
670836
} else {
671837
parse_str($query, $params);
672838
$offset = isset($params['page'][$filename]['offset']) ? (int)(sanitizeInput($params['page'][$filename]['offset'])) : 0;
673-
$perPage = isset($params['page'][$filename]['limit']) ? (int)(sanitizeInput($params['page'][$filename]['limit'])) : 10;
839+
$perPage = isset($params['page'][$filename]['limit']) ? (int)(sanitizeInput($params['page'][$filename]['limit'])) : 50;
674840
$exactMatch = false;
675841
if(isset($params['filter'])) {
676842
// Handle search request
@@ -778,14 +944,11 @@ public function getHeaders(): array {
778944
exit;
779945
}
780946

947+
// Get the updated record to return complete data
948+
$updatedRecord = $csvHandler->getById($id);
949+
781950
http_response_code(200);
782-
die(json_encode([
783-
'data' => [
784-
'type' => pathinfo($filename, PATHINFO_FILENAME),
785-
'id' => (string)$id,
786-
'attributes' => $input['data']['attributes']
787-
]
788-
]));
951+
die(json_encode($updatedRecord));
789952
} catch (InvalidArgumentException $e) {
790953
http_response_code(400);
791954
die(json_encode([

0 commit comments

Comments
 (0)