Skip to content
Open
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
26 changes: 26 additions & 0 deletions .changeset/mighty-windows-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@shopware/api-client": minor
---

Added helper to support encoded `_criteria` field in GET query parameters.
Context information: https://github.com/shopware/shopware/issues/12388

This helper is available under the `@shopware/api-client/helpers` import path.

```typescript
import { encodeForQuery } from "@shopware/api-client/helpers";

const criteria = {
page: 1,
limit: 10,
...
}

const encodedCriteria = encodeForQuery(criteria);

const result = await apiClient.invoke("getProducts get /product", {
query: {
_criteria: encodedCriteria,
},
});
```
1 change: 1 addition & 0 deletions .github/workflows/codspeed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ jobs:
- name: Run benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: "instrumentation"
run: pnpm run test:bench
48 changes: 48 additions & 0 deletions packages/api-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,54 @@ apiClient.updateBaseConfig({

This allows you to dynamically change the API endpoint or access token during runtime, for example when switching between different environments or when the access token needs to be updated.

## Helper Functions

The API client provides helper functions that can be imported separately to keep your main bundle size smaller.

### encodeForQuery

The `encodeForQuery` function compresses and encodes objects into base64url format for use in query strings. This is particularly useful for complex criteria objects that need to be passed as URL parameters.

Related issue: https://github.com/shopware/shopware/issues/12388

```typescript
import { encodeForQuery } from "@shopware/api-client/helpers";

// Example: Encoding complex search criteria
const criteria = {
page: 1,
limit: 10,
filter: [
{
type: "equals",
field: "active",
value: true
},
{
type: "contains",
field: "name",
value: "smartphone"
}
],
associations: {
manufacturer: {},
categories: {
associations: {
media: {}
}
}
}
};

// Use in URL
apiClient.invoke("getProducts get /product", {
query: {
_criteria: encodeForQuery(encodedCriteria),
},
});
```


## Links

- [🧑‍🎓 Tutorial](https://api-client-tutorial-composable-frontends.pages.dev)
Expand Down
4 changes: 2 additions & 2 deletions packages/api-client/build.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { defineBuildConfig } from "unbuild";
export default defineBuildConfig({
entries: ["src/index"],
entries: ["src/index", { input: "src/helpers/index", name: "helpers" }],
declaration: true,
rollup: {
cjsBridge: true,
emitCJS: true,
},
externals: [],
externals: ["fflate"],
});
11 changes: 11 additions & 0 deletions packages/api-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
"default": "./dist/index.mjs"
}
},
"./helpers": {
"require": {
"types": "./dist/helpers.d.cts",
"default": "./dist/helpers.cjs"
},
"import": {
"types": "./dist/helpers.d.mts",
"default": "./dist/helpers.mjs"
}
},
"./api-types": "./api-types/storeApiTypes.d.ts",
"./store-api-types": "./api-types/storeApiTypes.d.ts",
"./admin-api-types": "./api-types/adminApiTypes.d.ts"
Expand All @@ -51,6 +61,7 @@
"@codspeed/vitest-plugin": "4.0.1",
"@types/prettier": "3.0.0",
"@vitest/coverage-v8": "3.2.4",
"fflate": "0.8.2",
"jsdom": "26.1.0",
"prettier": "3.6.2",
"tsconfig": "workspace:*",
Expand Down
188 changes: 188 additions & 0 deletions packages/api-client/src/helpers/encodeForQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, expect, it } from "vitest";
import { encodeForQuery } from "./encodeForQuery";

describe("encodeForQuery", () => {
it("should encode a simple object", () => {
const obj = { name: "test", value: 123 };
const result = encodeForQuery(obj);

// Result should be a base64url encoded string
expect(typeof result).toBe("string");
expect(result).not.toContain("+");
expect(result).not.toContain("/");
expect(result).not.toContain("=");
});

it("should handle empty object", () => {
const obj = {};
const result = encodeForQuery(obj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should handle null value", () => {
const result = encodeForQuery(null);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should handle undefined value", () => {
const result = encodeForQuery(undefined);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should handle complex nested object", () => {
const obj = {
criteria: {
page: 1,
limit: 10,
filter: [
{
type: "equals",
field: "active",
value: true,
},
{
type: "contains",
field: "name",
value: "test",
},
],
associations: {
manufacturer: {},
categories: {
associations: {
media: {},
},
},
},
},
};

const result = encodeForQuery(obj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// Should be base64url encoded
expect(result).not.toContain("+");
expect(result).not.toContain("/");
expect(result).not.toContain("=");
});

it("should handle array values", () => {
const obj = {
ids: ["id1", "id2", "id3"],
numbers: [1, 2, 3, 4, 5],
};

const result = encodeForQuery(obj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should handle string values", () => {
const obj = "simple string";
const result = encodeForQuery(obj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should handle boolean values", () => {
const result1 = encodeForQuery(true);
const result2 = encodeForQuery(false);

expect(typeof result1).toBe("string");
expect(typeof result2).toBe("string");
expect(result1).not.toBe(result2);
});

it("should handle number values", () => {
const result = encodeForQuery(12345);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should produce consistent results for same input", () => {
const obj = { test: "value", number: 42 };
const result1 = encodeForQuery(obj);
const result2 = encodeForQuery(obj);

expect(result1).toBe(result2);
});

it("should produce different results for different inputs", () => {
const obj1 = { test: "value1" };
const obj2 = { test: "value2" };
const result1 = encodeForQuery(obj1);
const result2 = encodeForQuery(obj2);

expect(result1).not.toBe(result2);
});

it("should handle special characters in strings", () => {
const obj = {
text: "Special chars: äöü ßÄÖÜ 中文 🚀 @#$%^&*()",
emoji: "🎉🎊🎈",
};

const result = encodeForQuery(obj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should compress large objects efficiently", () => {
// Create a large object with repetitive data that should compress well
const largeObj = {
items: Array(100)
.fill(0)
.map((_, i) => ({
id: `item-${i}`,
name: `Test Item ${i}`,
description:
"This is a test item with a long description that repeats many times",
active: true,
price: 99.99,
category: "test-category",
})),
};

const result = encodeForQuery(largeObj);
const jsonString = JSON.stringify(largeObj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// The compressed result should be significantly smaller than the JSON string
// Note: This is a rough estimate, actual compression ratio depends on the data
expect(result.length).toBeLessThan(jsonString.length);
});

it("should handle date objects", () => {
const obj = {
createdAt: new Date("2023-01-01T00:00:00Z"),
updatedAt: new Date(),
};

const result = encodeForQuery(obj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});

it("should be base64url compliant", () => {
const obj = { test: "value with spaces and special chars: +/=" };
const result = encodeForQuery(obj);

// Base64url should not contain +, /, or = characters
expect(result).not.toMatch(/[\+\/=]/);
// Should only contain URL-safe characters
expect(result).toMatch(/^[A-Za-z0-9_-]*$/);
});
});
17 changes: 17 additions & 0 deletions packages/api-client/src/helpers/encodeForQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { gzipSync, strToU8 } from "fflate";

/**
* 🔹 Compress object -> base64url for query string
*
* This is a helper to support https://github.com/shopware/shopware/issues/12388 for _criteria query field in store-api GET requests
*
*/
export function encodeForQuery(obj: unknown): string {
const json = JSON.stringify(obj);
const compressed = gzipSync(strToU8(json));

// Convert to base64url
let base64 = btoa(String.fromCharCode.apply(null, Array.from(compressed)));
base64 = base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return base64;
}
20 changes: 20 additions & 0 deletions packages/api-client/src/helpers/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { encodeForQuery } from "./index";

describe("helpers/index", () => {
it("should export encodeForQuery function", () => {
expect(typeof encodeForQuery).toBe("function");
});

it("should work correctly when imported from helpers chunk", () => {
const testObj = { test: "value", number: 42 };
const result = encodeForQuery(testObj);

expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// Should be base64url encoded
expect(result).not.toContain("+");
expect(result).not.toContain("/");
expect(result).not.toContain("=");
});
});
1 change: 1 addition & 0 deletions packages/api-client/src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { encodeForQuery } from "./encodeForQuery";
Loading
Loading