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
27 changes: 27 additions & 0 deletions projects/js-packages/svelte-data-sync-client/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module.exports = {
extends: [ require.resolve( 'jetpack-js-tools/eslintrc/typescript' ) ],
rules: {
'jsdoc/check-alignment': 0,
'jsdoc/check-examples': 0,
'jsdoc/check-indentation': 0,
'jsdoc/check-param-names': 0,
'jsdoc/check-syntax': 0,
'jsdoc/check-tag-names': 0,
'jsdoc/check-types': 0,
'jsdoc/implements-on-classes': 0,
'jsdoc/newline-after-description': 0,
'jsdoc/require-description': 0,
'jsdoc/require-hyphen-before-param-description': 0,
'jsdoc/require-jsdoc': 0,
'jsdoc/require-param': 0,
'jsdoc/require-param-description': 0,
'jsdoc/require-param-name': 0,
'jsdoc/require-param-type': 0,
'jsdoc/require-returns': 0,
'jsdoc/require-returns-check': 0,
'jsdoc/require-returns-description': 0,
'jsdoc/require-returns-type': 0,
'jsdoc/valid-types': 0,
'jsdoc/check-values': 0,
},
};
139 changes: 131 additions & 8 deletions projects/js-packages/svelte-data-sync-client/README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,143 @@
# svelte-data-sync-client
# Data Sync Client for Svelte

A Svelte.js client for the wp-js-data-sync package
This package is intended to be used together with the [`@jetpack/packages/wp-js-data-sync`](https://github.com/Automattic/jetpack/blob/trunk/projects/packages/wp-js-data-sync/) package.

## How to install svelte-data-sync-client
The Data Sync Client for Svelte creates Type-safe [Svelte stores](https://svelte.dev/docs#run-time-svelte-store) that automatically sync with WordPress via the REST API and preload values using `wp_localize_script`.

### Installation From Git Repo
## Usage

## Contribute
### Step 1: Initialize the Client

First, you need to initialize the client, for example in `options.ts` file, this is going to create a namespaced factory that you can use to create stores:

```ts
// favorites.ts
import { initializeClient } from '@automattic/jetpack-svelte-data-sync-client';
const client = initializeClient('jetpack_favorites');
```

### Step 2: Setup type safe stores:

[Zod](https://zod.dev) is used to ensure that the values are properly typed and match the expectations.

**Important Zod methods**:
Zod is a flexible library that helps validate values at run-time.

This gives you a lot of flexibility setting up the types, depending on how they're used and how they should be handled when something goes wrong.

Here are a couple important Zod methods to know about:

- [z.optional](https://github.com/colinhacks/zod#optional) - This is used to mark a field as optional, but it will still be validated if it's present.
- [z.default](https://github.com/colinhacks/zod#default) - This is used to set a default value for a field if it's undefined, but will not override a value if it's already set, even if the type doesn't match.
- [z.catch](https://github.com/colinhacks/zod#catch) - This is used to catch errors and return a default value if the type doesn't match.
- [z.passthrough](https://github.com/colinhacks/zod#passthrough) - By default Zod object schemas strip out unrecognized keys during parsing. Using `passthrough` will allow unrecognized keys to be passed through.

```ts
import { z } from 'zod';

// favorites.ts
const favorite_post_schema = z.object({
id: z.number(),
title: z.string(),
});

export const favorites = {
enabled: client.createAsyncStore('favorite_posts_enabled', z.boolean().catch(false)),
posts: client.createAsyncStore('favorite_posts', z.array(favorite_post_schema).catch([])),
};
```

That's it, now you can use `favorites.enabled` and `favorites.posts` in your Svelte components.

#### Step 3: Store Usage

Use `client.createAsyncStore()` to create an object with two Svelte stores:

- `store`: Use this as a normal Svelte store. When the value is updated, it will dispatch POST requests to the REST API endpoint.
- `pending`: In case you need to display a loading state, this store will be `true` while the value is being updated.

Here's a simple example of how that would work:

```svelte
<script type="ts">
import { favorites } from "./favorites.ts";
const enabled = favorites.enabled.store;
const pending = favorites.enabled.pending;
</script>

{#if $pending}
🌊 I'm updating the value
{/if}

<label for="favorite-posts-enabled">
<input type="checkbox" bind:checked={$enabled} />
Enable
</label>
```

#### Interacting with REST API

Every created Data Sync Client Store will also have an `.endpoints` property that can be used to interact with the REST API endpoints directly if needed (for example, to refresh a value).

```ts
// favorites.ts
const result = await favorites.enabled.endpoints.GET();
const result = await favorites.enabled.endpoints.POST( true );;
```

Note that the endpoint methods are type-safe too, so you can't pass a value that doesn't match the schema. If you do, errors will be thrown.

If you need to interact with the REST API endpoints directly, you can use the [API](./src/API.ts) class directly:

```ts
const api = new API();
// API Must be initialized with a nonce, otherwise WordPress REST API will return a 403 error.
api.initialize( 'jetpack_favorites', window.jetpack_favorites.rest_api.nonce );

// Send a request to any endpoint:
const result = await api.request( 'GET', 'foobar', "<endpoint-nonce>");
```

To dive in deeper, have a look at [API](./src/API.ts) and [Endpoint](./src/Endpoint.ts) source files.

#### Putting it all together

Here's all the boilerplate code to get you started quickly:

```ts
// favorites.ts
import { z } from 'zod';
import { initializeClient } from '@automattic/jetpack-svelte-data-sync-client';

const client = initializeClient('jetpack_favorites');

const favorite_post_schema = z.object({
ID: z.number(),
post_title: z.string(),
});

export const favorites = {
enabled: client.createAsyncStore('enabled', z.boolean().catch(false)),
posts: client.createAsyncStore('posts', z.array(favorite_post_schema),
};
```

And use the stores in Svelte.

```svelte
<div class="posts">
<h1>Posts</h1>
{#each $posts as post}
<h1>{post.post_title} ({post.ID})</h1>
{/each}
</div>
```

## Get Help

## Security

Need to report a security vulnerability? Go to [https://automattic.com/security/](https://automattic.com/security/) or directly to our security bug bounty site [https://hackerone.com/automattic](https://hackerone.com/automattic).

## License

svelte-data-sync-client is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt)

Svelte Data Sync Client is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: major
Type: added

First release
98 changes: 98 additions & 0 deletions projects/js-packages/svelte-data-sync-client/src/API.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* eslint-disable no-console */
import { JSONSchema } from './utils';
export type RequestParams = string | JSONSchema;
export type RequestMethods = 'GET' | 'POST' | 'DELETE';
export class API {
private baseUrl: string;
private restNonce: string;

/**
* The API class must be initialized with
* the base URL and the REST nonce.
*
* @param baseUrl - For example: http://localhost/wp-json/jetpack-favorites
* @param restNonce - For example: abcdefghij
*/
public initialize( baseUrl: string, restNonce: string ) {
this.baseUrl = baseUrl;
this.restNonce = restNonce;
}

public isInitialized() {
return !! this.baseUrl && !! this.restNonce;
}

/**
* The API Class should already be initialized with
* the Base URL (that includes the REST namespace) and the REST nonce.
* @see initialize
*
* So request methods need only the endpoint path,
* For example:
* ```js
* const api = new API();
* api.initialize( 'http://localhost/wp-json/jetpack-favorites', 'abcdefghij' );
* api.request( 'posts' );
* ```
* This would make a request to: http://localhost/wp-json/jetpack-favorites/posts
*/
public async request(
partialPathname: string,
method: RequestMethods = 'GET',
endpointNonce: string,
params?: RequestParams
) {
if ( ! this.isInitialized() ) {
console.error( 'API is not initialized', {
baseUrl: this.baseUrl,
restNonce: this.restNonce,
} );
return null;
}

const url = `${ this.baseUrl }/${ partialPathname }`;

const args: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': this.restNonce,
'X-Jetpack-WP-JS-Sync-Nonce': endpointNonce,
},
credentials: 'same-origin',
body: null,
};

if ( method === 'POST' && params ) {
args.body = JSON.stringify( { JSON: params } );
}

const result = await fetch( url, args );
if ( ! result.ok ) {
console.error( 'Failed to fetch', url, result );
throw new Error( `Failed to "${ method }" to ${ url }. Received ${ result.status }` );
}

let data;
const text = await result.text();
try {
data = JSON.parse( text );
} catch ( e ) {
console.error( 'Failed to parse the response\n', { url, text, result, error: e } );
}

/**
* `data.JSON` is used to keep in line with how WP REST API parses request json params
* It also allows frees up the the endpoint to accept other values in the root of the JSON object
* if that ever becomes necessary.
* @see https://developer.wordpress.org/reference/classes/wp_rest_request/parse_json_params/
* @see https://github.com/WordPress/wordpress-develop/blob/28f10e4af559c9b4dbbd1768feff0bae575d5e78/src/wp-includes/rest-api/class-wp-rest-request.php#L701
*/
if ( ! data || data.JSON === undefined ) {
console.error( 'Failed to parse the response\n', { url, text, result } );
throw new Error( `Failed to "${ method }" to ${ url }. Received ${ result.status }` );
}

return data.JSON;
Copy link
Member

@dilirity dilirity Feb 7, 2023

Choose a reason for hiding this comment

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

Why not assign .JSON directly to data? The whole object isn't used anyway. It would also simplify the check on line 68.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a comment:

		/**
		 * `data.JSON` is used to keep in line with how WP REST API parses request json params
		 * It also allows frees up the the endpoint to accept other values in the root of the JSON object
		 * if that ever becomes necessary.
		 * @see https://developer.wordpress.org/reference/classes/wp_rest_request/parse_json_params/
		 * @see https://github.com/WordPress/wordpress-develop/blob/28f10e4af559c9b4dbbd1768feff0bae575d5e78/src/wp-includes/rest-api/class-wp-rest-request.php#L701
		 */

Copy link
Member

Choose a reason for hiding this comment

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

Typo - allows frees up.

}
}
50 changes: 50 additions & 0 deletions projects/js-packages/svelte-data-sync-client/src/Endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from 'zod';
import { API, RequestMethods, RequestParams } from './API';

/**
* Every SyncedStore option has its own API Endpoint.
*/
export class API_Endpoint< T extends RequestParams > {
public nonce = '';

private endpoint: string;

constructor( private api: API, private name: string, private schema: z.ZodSchema ) {
/**
* Convert underscores to dashes,
* because all endpoints are kebab-case and options are snake_case.
* For example, `jetpack_favorites` becomes `jetpack-favorites`.
*
* For more information on the shape of the API,
* @see API.request
*/
this.endpoint = this.name.replace( '_', '-' );
}

public async validatedRequest( method: RequestMethods = 'GET', params?: T ): Promise< T > {
const request = this.api.request( this.endpoint, method, this.nonce, params );
return await request.then( data => {
return this.schema.parse( data );
} );
}

/**
* Class member variables:
*
* Variables below are class member variables, instead of class methods,
* because they need to be bound to the class instance, to make it
* easier to pass them around as callbacks
* without losing the `this` context.
*/
public GET = async (): Promise< T > => {
return await this.validatedRequest( 'GET' );
};

public POST = async ( params: T ): Promise< T > => {
return await this.validatedRequest( 'POST', params );
};

public DELETE = async () => {
return await this.validatedRequest( 'DELETE' );
};
Comment on lines +43 to +49
Copy link
Member

Choose a reason for hiding this comment

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

Why are these written as class member variables that contain a function instead of as functions? (i.e.: like GET)

Copy link
Contributor Author

@pyronaur pyronaur Feb 7, 2023

Choose a reason for hiding this comment

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

So that they can be run in a callback without losing this - seems worth adding a comment

}
Loading