Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions src/Kinds/K8sResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,21 +145,49 @@ public function toJson($options = 0, ?string $kind = null): string|false
}

/**
* Convert the object to its JSON representation, but
* escaping [] for {}. Optionally, you can specify
* Convert the object to its JSON representation, coercing empty
* map fields from [] to {}. Optionally, you can specify
* the Kind attribute to replace.
*
* @return string
*/
public function toJsonPayload(?string $kind = null): string|false
{
$attributes = $this->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES, $kind);
$coerced = $this->coerceEmptyArraysToObjects($this->toArray($kind));

return json_encode($coerced, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}

/**
* Kubernetes map fields are encoded by PHP's json_encode as `[]` when empty,
* but the API expects `{}` for objects. Walk the decoded structure and convert
* every empty array into an empty object so the byte sequence is only ever
* changed at structural positions — never inside string scalars (which would
* corrupt embedded scripts such as `glob(...) ?: []`).
*
* A handful of genuine list fields must stay as `[]` even when empty; those
* keys are exempted by name at any nesting depth.
*
* @param array<array-key, mixed> $attributes
* @return array<array-key, mixed>
*/
protected function coerceEmptyArraysToObjects(array $attributes): array
{
$emptyArrayLists = ['allowedTopologies', 'mountOptions', 'accessModes'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The $emptyArrayLists array literal is allocated fresh on every recursive invocation. For a deeply nested resource structure this creates unnecessary allocations on each stack frame. Declaring it as a static local avoids the per-call cost and makes the intent clearer.

Suggested change
$emptyArrayLists = ['allowedTopologies', 'mountOptions', 'accessModes'];
static $emptyArrayLists = ['allowedTopologies', 'mountOptions', 'accessModes'];

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


foreach ($attributes as $key => $value) {
if (! is_array($value)) {
continue;
}

$attributes = str_replace(': []', ': {}', $attributes);
if ($value === []) {
if (! in_array($key, $emptyArrayLists, true)) {
$attributes[$key] = (object) [];
}

$attributes = str_replace('"allowedTopologies": {}', '"allowedTopologies": []', $attributes);
$attributes = str_replace('"mountOptions": {}', '"mountOptions": []', $attributes);
$attributes = str_replace('"accessModes": {}', '"accessModes": []', $attributes);
continue;
}

$attributes[$key] = $this->coerceEmptyArraysToObjects($value);
}

return $attributes;
}
Expand Down
113 changes: 113 additions & 0 deletions tests/JsonPayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace RenokiCo\PhpK8s\Test;

use RenokiCo\PhpK8s\K8s;

class JsonPayloadTest extends TestCase
{
public function test_string_values_containing_empty_array_literal_are_not_corrupted()
{
$container = K8s::container()
->setName('backup')
->setImage('public.ecr.aws/docker/library/php', '8.4')
->setCommand(['php', '-r', 'foreach (glob("/x/*") ?: [] as $file) { echo $file; }']);

$pod = $this->cluster->pod()
->setName('backup')
->setContainers([$container]);

$job = $this->cluster->job()
->setName('backup')
->setTemplate($pod);

$payload = $job->toJsonPayload();
$decoded = json_decode($payload, true);

$command = $decoded['spec']['template']['spec']['containers'][0]['command'][2];

$this->assertSame('foreach (glob("/x/*") ?: [] as $file) { echo $file; }', $command);
$this->assertStringNotContainsString('?: {}', $payload);
}

public function test_generic_colon_space_bracket_string_is_preserved()
{
$container = K8s::container()
->setName('echo')
->setImage('public.ecr.aws/docker/library/busybox')
->setCommand(['/bin/sh', '-c', 'echo arr: [] done']);

$pod = $this->cluster->pod()
->setName('echo')
->setContainers([$container]);

$decoded = json_decode($pod->toJsonPayload(), true);

$this->assertSame('echo arr: [] done', $decoded['spec']['containers'][0]['command'][2]);
}

public function test_non_empty_list_arrays_stay_lists()
{
$container = K8s::container()
->setName('echo')
->setImage('public.ecr.aws/docker/library/busybox')
->setCommand(['/bin/sh', '-c', 'true']);

$pod = $this->cluster->pod()
->setName('echo')
->setContainers([$container]);

$decoded = json_decode($pod->toJsonPayload(), true);

$this->assertTrue(array_is_list($decoded['spec']['containers']));
$this->assertCount(1, $decoded['spec']['containers']);
}

public function test_empty_map_fields_are_coerced_to_objects()
{
$configMap = $this->cluster->configmap()
->setName('settings')
->setLabels([])
->setData([]);

$payload = $configMap->toJsonPayload();

$this->assertStringContainsString('"data": {}', $payload);
$this->assertStringContainsString('"labels": {}', $payload);
}

public function test_exempted_list_fields_stay_empty_arrays()
{
$storageClass = $this->cluster->storageClass()
->setName('standard')
->setProvisioner('csi.example.com');

$storageClass->setAttribute('mountOptions', []);
$storageClass->setAttribute('allowedTopologies', []);
$storageClass->setAttribute('accessModes', []);

$payload = $storageClass->toJsonPayload();

$this->assertStringContainsString('"mountOptions": []', $payload);
$this->assertStringContainsString('"allowedTopologies": []', $payload);
$this->assertStringContainsString('"accessModes": []', $payload);
}

public function test_nested_empty_map_deep_in_spec_is_coerced()
{
$container = K8s::container()
->setName('echo')
->setImage('public.ecr.aws/docker/library/busybox')
->setCommand(['/bin/sh', '-c', 'true']);

$pod = $this->cluster->pod()
->setName('echo')
->setContainers([$container]);

$pod->setAttribute('spec.nodeSelector', []);

$payload = $pod->toJsonPayload();

$this->assertStringContainsString('"nodeSelector": {}', $payload);
}
}