├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── examples ├── create_new_record.ts └── upload_asset.ts ├── package-lock.json ├── package.json ├── src ├── index.ts ├── request.ts ├── sign.ts ├── types.ts └── url.ts ├── tests └── url.test.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: danjohnson95 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | eckey.pem 4 | secrets.ts 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudKit JS 2 | 3 | CloudKit JS aims to make it easy for you to manage your CloudKit container from your Node.js server. 4 | 5 | It's as simple as this 6 | 7 | ```javascript 8 | import { CloudKitJs } from 'cloudkit-js'; 9 | 10 | const lib = new CloudKitJs({ 11 | containerName: "iCloud.com.your.container.name", 12 | keyId: "xxx", 13 | privateKeyPath: "eckey.pem" 14 | }) 15 | 16 | lib.createRecord({ 17 | recordType: "MyRecordType", 18 | fields: { 19 | name: { value: "My new record" }, 20 | description: { value: "Wow, so easy!" } 21 | } 22 | }) 23 | ``` 24 | 25 | ## Getting started 26 | 27 | This assumes you've already an active Apple Developer membership and you've got your CloudKit container set up. 28 | 29 | ### 1. Create a server-to-server key in CloudKit 30 | 31 | Go to [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/), open your container, and click "Tokens & Keys" on the left menu. 32 | 33 | Add a new "Server-to-Server Key" and follow the instructions. 34 | 35 | ### 2. Install the library 36 | 37 | ```bash 38 | npm install --save cloudkit-js 39 | ``` 40 | 41 | ### 3. Initialise the library 42 | 43 | ```javascript 44 | import { CloudKitJs } from 'cloudkit-js'; 45 | 46 | const lib = new CloudKitJs({ 47 | containerName: "iCloud.com.your.container.name", // Update this with your CloudKit container name 48 | keyId: "xxx", // Put your key ID here 49 | privateKeyPath: "eckey.pem" // Point to your private key file 50 | }) 51 | ``` 52 | 53 | The `keyId` and `privateKeyPath` are the values you got from step 1. 54 | 55 | Your Key ID is displayed when viewing the server-to-server key on the CloudKit Dashboard. 56 | 57 | > ⚠️ These are secret, so obvz don't put them in source control. 58 | -------------------------------------------------------------------------------- /examples/create_new_record.ts: -------------------------------------------------------------------------------- 1 | import secrets from "../secrets"; 2 | import { CloudKitJs } from "../src"; 3 | 4 | const orm = new CloudKitJs({ 5 | containerName: secrets.containerName, 6 | keyId: secrets.keyId, 7 | privateKeyPath: secrets.privateKeyPath, 8 | }) 9 | 10 | /** 11 | * Creates a new recipe. 12 | */ 13 | const createNewRecipe = async ({ 14 | recipeName, 15 | ingredients, 16 | method, 17 | timeToCook, 18 | rating 19 | }: { 20 | recipeName: string, 21 | ingredients: string[], 22 | method: string[], 23 | timeToCook: number, 24 | rating: number 25 | }) => { 26 | await orm.createRecord({ 27 | recordType: "Recipes", 28 | fields: { 29 | recipeName: { value: recipeName }, 30 | ingredients: { value: ingredients }, 31 | method: { value: method }, 32 | timeToCook: { value: timeToCook }, 33 | rating: { value: rating } 34 | } 35 | }) 36 | 37 | console.log(`${recipeName} was successfully created.`) 38 | } 39 | 40 | // Now let's create a recipe for a delicious pasta dish. 41 | createNewRecipe({ 42 | recipeName: "Cacio e Pepe", 43 | ingredients: [ 44 | "Pasta", 45 | "Pecorino Romano", 46 | "Black Pepper" 47 | ], 48 | method: [ 49 | "Boil water", 50 | "Add pasta", 51 | "Grate cheese", 52 | "Add cheese and pepper to pasta", 53 | "Mix" 54 | ], 55 | timeToCook: 15, 56 | rating: 5 57 | }) -------------------------------------------------------------------------------- /examples/upload_asset.ts: -------------------------------------------------------------------------------- 1 | import secrets from "../secrets"; 2 | import { CloudKitJs } from "../src"; 3 | 4 | const orm = new CloudKitJs({ 5 | containerName: secrets.containerName, 6 | keyId: secrets.keyId, 7 | privateKeyPath: secrets.privateKeyPath, 8 | }) 9 | 10 | /** 11 | * Counts all Recipes. 12 | */ 13 | const uploadImageToRecipe = async (imageUrl: string, recipeName: string) => { 14 | // We've got our recipe name, but we need the RecordName. 15 | // So let's query for that. 16 | const resp = await orm.queryRecords({ 17 | desiredKeys: ["recordName"], 18 | resultsLimit: 1, 19 | query: { 20 | recordType: "Recipe", 21 | filterBy: { 22 | fieldName: "name", 23 | comparator: "EQUALS", 24 | fieldValue: { 25 | value: recipeName, 26 | } 27 | } 28 | } 29 | }) 30 | 31 | const recordName = resp.records[0].recordName 32 | 33 | // Now let's upload our image to the recipe. 34 | await orm.uploadAssetFromUrl( 35 | "Recipe", // The record type 36 | "image", // The name of the field where the asset should be stored 37 | imageUrl, 38 | recordName 39 | ) 40 | } 41 | 42 | // Let's say we want to upload an image to our Cacio e Pepe recipe. 43 | uploadImageToRecipe( 44 | "https://images.unsplash.com/photo-1663153465971-7fbb838043ca", 45 | "Cacio e Pepe" 46 | ) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudkit-js", 3 | "version": "0.0.3", 4 | "description": "A JS library for managing a CloudKit container", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "jest" 10 | }, 11 | "engines": { 12 | "node": ">=18.6" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/danjohnson95/cloudkit-js.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/danjohnson95/cloudkit-js/issues" 23 | }, 24 | "keywords": [ 25 | "orm", 26 | "cloudkit", 27 | "apple" 28 | ], 29 | "author": "Dan Johnson ", 30 | "license": "ISC", 31 | "devDependencies": { 32 | "@babel/preset-env": "^7.25.3", 33 | "@babel/preset-typescript": "^7.24.7", 34 | "@types/jest": "^29.5.12", 35 | "jest": "^29.7.0", 36 | "ts-node": "^10.9.1", 37 | "typescript": "^5.1.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { SignService } from "./sign"; 3 | import { RequestService } from "./request"; 4 | import { UrlBuilder } from "./url"; 5 | import { CreateRecordOptions, DeleteRecordOptions, InitParams, QueryRecordOptions, RecordQueryResponse, RecordType, UpdateRecordOptions } from './types'; 6 | 7 | export class CloudKitJs { 8 | protected readonly requestService: RequestService 9 | protected readonly urlBuilder: UrlBuilder 10 | 11 | constructor(protected initParams: InitParams) { 12 | if (!initParams.privateKeyContents && !initParams.privateKeyPath) { 13 | throw new Error("You must specify either privateKeyPath or privateKeyContents") 14 | } 15 | 16 | const signService = new SignService( 17 | initParams.privateKeyContents || fs.readFileSync(initParams.privateKeyPath!), 18 | initParams.keyId 19 | ) 20 | 21 | this.requestService = new RequestService(signService) 22 | 23 | this.urlBuilder = new UrlBuilder({ 24 | containerName: initParams.containerName, 25 | environment: initParams.shouldUseProduction ? "production" : "development", 26 | database: initParams?.database || "public" 27 | }) 28 | } 29 | 30 | public async queryRecords(queryRecordOptions: QueryRecordOptions) { 31 | const payload = JSON.stringify(queryRecordOptions) 32 | 33 | return this.requestService.makeRequest( 34 | this.urlBuilder.getQueryRecordsPath(), 35 | payload, 36 | ) 37 | } 38 | 39 | public async getRecordByName(recordName: string, desiredKeys?: string[]) { 40 | const payload = JSON.stringify({ 41 | records: [{ 42 | recordName, 43 | desiredKeys 44 | }] 45 | }) 46 | 47 | return this.requestService.makeRequest( 48 | this.urlBuilder.getLookupRecordsPath(), 49 | payload 50 | ) 51 | } 52 | 53 | public async createRecord(createRecordOptions: CreateRecordOptions) { 54 | const payload = JSON.stringify({ 55 | operations: [{ 56 | operationType: "create", 57 | record: { 58 | recordType: createRecordOptions.recordType, 59 | fields: createRecordOptions.fields, 60 | } 61 | }] 62 | }) 63 | 64 | return this.requestService.makeRequest( 65 | this.urlBuilder.getModifyRecordsPath(), 66 | payload 67 | ) 68 | } 69 | 70 | public async createRecords(records: CreateRecordOptions[]) { 71 | const payload = JSON.stringify({ 72 | operations: records.map(record => ({ 73 | operationType: "create", 74 | record: { 75 | recordType: record.recordType, 76 | fields: record.fields, 77 | recordName: record.recordName, 78 | } 79 | })) 80 | }) 81 | 82 | return this.requestService.makeRequest( 83 | this.urlBuilder.getModifyRecordsPath(), 84 | payload 85 | ) 86 | } 87 | 88 | public async updateRecord(recordName: string, recordType: RecordType, fields: { [index in string]: any }) { 89 | const payload = JSON.stringify({ 90 | operations: [{ 91 | operationType: "update", 92 | record: { 93 | recordName, 94 | recordType, 95 | fields 96 | } 97 | }] 98 | }) 99 | 100 | return this.requestService.makeRequest( 101 | this.urlBuilder.getModifyRecordsPath(), 102 | payload 103 | ) 104 | } 105 | 106 | public async forceUpdateRecord(recordName: string, recordType: RecordType, fields: { [index in string]: any }) { 107 | const payload = JSON.stringify({ 108 | operations: [{ 109 | operationType: "forceUpdate", 110 | record: { 111 | recordName, 112 | recordType, 113 | fields 114 | } 115 | }] 116 | }) 117 | 118 | return this.requestService.makeRequest( 119 | this.urlBuilder.getModifyRecordsPath(), 120 | payload 121 | ) 122 | } 123 | 124 | public async deleteRecord(deleteRecordOptions: DeleteRecordOptions) { 125 | const payload = JSON.stringify({ 126 | operations: [{ 127 | operationType: "delete", 128 | record: { 129 | recordName: deleteRecordOptions.recordName, 130 | recordType: deleteRecordOptions.recordType, 131 | recordChangeTag: deleteRecordOptions.recordChangeTag 132 | } 133 | }] 134 | }) 135 | 136 | return this.requestService.makeRequest( 137 | this.urlBuilder.getModifyRecordsPath(), 138 | payload 139 | ) 140 | } 141 | 142 | public async forceDeleteRecord(deleteRecordOptions: DeleteRecordOptions) { 143 | const payload = JSON.stringify({ 144 | operations: [{ 145 | operationType: "forceDelete", 146 | record: { 147 | recordName: deleteRecordOptions.recordName, 148 | recordType: deleteRecordOptions.recordType, 149 | } 150 | }] 151 | }) 152 | 153 | return this.requestService.makeRequest( 154 | this.urlBuilder.getModifyRecordsPath(), 155 | payload 156 | ) 157 | } 158 | 159 | public async forceDeleteRecords(recordType: RecordType, recordNames: string[]) { 160 | const payload = JSON.stringify({ 161 | operations: recordNames.map(recordName => ({ 162 | operationType: "forceDelete", 163 | record: { 164 | recordName, 165 | recordType, 166 | 167 | } 168 | })) 169 | }) 170 | 171 | return this.requestService.makeRequest( 172 | this.urlBuilder.getModifyRecordsPath(), 173 | payload 174 | ) 175 | } 176 | 177 | public async uploadAssetFromUrl(recordType: RecordType, fieldName: string, fileUrl: string, recordName: string) { 178 | // Download the asset from fileUrl 179 | const buffer = await (await fetch(fileUrl)).blob() 180 | 181 | return this.uploadAsset( 182 | recordType, 183 | fieldName, 184 | buffer, 185 | recordName 186 | ) 187 | } 188 | 189 | public async uploadAsset(recordType: RecordType, fieldName: string, fileContents: Blob, recordName: string) { 190 | const payload = JSON.stringify({ 191 | tokens: [{ 192 | recordName, 193 | recordType, 194 | fieldName 195 | }] 196 | }) 197 | 198 | const resp = await this.requestService.makeRequest( 199 | this.urlBuilder.getUploadAssetsPath(), 200 | payload 201 | ) 202 | 203 | const uploadUrl = (resp as any).tokens[0].url 204 | 205 | const assetResp = await fetch(uploadUrl, { 206 | method: "POST", 207 | body: fileContents 208 | }) 209 | 210 | const imageResp = await assetResp.json() 211 | 212 | // Now we need to update the record with the asset 213 | return this.forceUpdateRecord( 214 | recordName, 215 | recordType, 216 | { 217 | [fieldName]: { 218 | value: imageResp.singleFile 219 | } 220 | } 221 | ) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | import https from "https" 2 | import { ISignService } from "./types"; 3 | import zlib from "zlib"; 4 | 5 | export class RequestService { 6 | protected readonly hostname = "api.apple-cloudkit.com"; 7 | protected readonly port = 443; 8 | 9 | constructor(protected readonly signService: ISignService) { } 10 | 11 | public makeRequest(requestPath: string, payload: string): Promise { 12 | const { dateString, signature } = this.signService.signRequest(payload, requestPath) 13 | 14 | const requestOptions = { 15 | hostname: this.hostname, 16 | port: this.port, 17 | path: requestPath, 18 | method: "POST", 19 | headers: { 20 | "X-Apple-CloudKit-Request-KeyID": this.signService.getKeyId(), 21 | "X-Apple-CloudKit-Request-ISO8601Date": dateString, 22 | "X-Apple-CloudKit-Request-SignatureV1": signature 23 | } 24 | } 25 | 26 | return new Promise((resolve, reject) => { 27 | const request = https.request(requestOptions, function(response) { 28 | const responseBody: Buffer[] = []; 29 | 30 | response.on("data", function (chunk) { 31 | responseBody.push(chunk) 32 | }); 33 | response.on("end", function () { 34 | let buffer = Buffer.concat(responseBody); 35 | const encoding = response.headers['content-encoding']; 36 | let responseObject = {} 37 | 38 | if (encoding === 'gzip') { 39 | zlib.gunzip(buffer, (err, decoded) => { 40 | if (err) { 41 | return reject(err); 42 | } 43 | 44 | try { 45 | responseObject = JSON.parse(decoded.toString('utf8')); 46 | } catch (e) { 47 | return reject(e); 48 | } 49 | 50 | resolve(responseObject as T); 51 | }); 52 | } else if (encoding === 'deflate') { 53 | zlib.inflate(buffer, (err, decoded) => { 54 | if (err) { 55 | return reject(err); 56 | } 57 | 58 | try { 59 | responseObject = JSON.parse(decoded.toString('utf8')); 60 | } catch (e) { 61 | return reject(e); 62 | } 63 | 64 | resolve(responseObject as T); 65 | }); 66 | } else { 67 | try { 68 | responseObject = JSON.parse(buffer.toString('utf8')); 69 | } catch (e) { 70 | return reject(e); 71 | } 72 | 73 | resolve(responseObject as T); 74 | } 75 | }); 76 | }) 77 | 78 | request.on("error", function(err) { 79 | reject(err) 80 | }) 81 | 82 | request.end(payload) 83 | }) 84 | } 85 | } -------------------------------------------------------------------------------- /src/sign.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import { ISignService } from './types' 3 | 4 | export class SignService implements ISignService { 5 | constructor(private readonly privateKey: Buffer, private readonly keyId: string) { 6 | } 7 | 8 | protected getDateString() { 9 | // We need to strip out the milliseconds 10 | return new Date().toISOString().replace(/\.[0-9]+?Z/, "Z") 11 | } 12 | 13 | public signRequest(payload: string, requestPath: string) { 14 | const hash = crypto.createHash("sha256") 15 | const sign = crypto.createSign("RSA-SHA256") 16 | 17 | const now = this.getDateString() 18 | 19 | // Hash the payload 20 | hash.update(payload, "utf8") 21 | 22 | // Base64 it 23 | const base64PayloadHash = hash.digest("base64") 24 | 25 | // Now create a signature. 26 | // It's comprised of [dateString]:[base64PayloadHash]:[requestPath] 27 | const signatureData = [ 28 | now, 29 | base64PayloadHash, 30 | requestPath 31 | ].join(":") 32 | 33 | // Now we'll RSA-SHA256 sign the signatureData with our private key 34 | sign.update(signatureData) 35 | 36 | return { 37 | signature: sign.sign(this.privateKey, "base64"), 38 | dateString: now 39 | } 40 | } 41 | 42 | public getKeyId() { 43 | return this.keyId 44 | } 45 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | declare class CloudKitJs { 2 | constructor(initParams: InitParams); 3 | queryRecords(queryRecordOptions: QueryRecordOptions): Promise 4 | getRecordByName(recordName: string, desiredKeys?: string[]): Promise 5 | createRecord(createRecordOptions: CreateRecordOptions): Promise 6 | createRecords(records: CreateRecordOptions[]): Promise 7 | deleteRecord(deleteRecordOptions: DeleteRecordOptions): Promise 8 | uploadAssetFromUrl(recordType: string, fieldName: string, url: string, recordName: string): Promise 9 | uploadAsset(recordType: string, fieldName: string, file: Buffer, recordName: string): Promise 10 | } 11 | 12 | export enum ComparatorValue { 13 | /** 14 | * The left-hand value is equal to the right-hand value. 15 | */ 16 | Equals = 'EQUALS', 17 | 18 | /** 19 | * The left-hand value is not equal to the right-hand value. 20 | */ 21 | NotEquals = 'NOT_EQUALS', 22 | 23 | /** 24 | * The left-hand value is less than the right-hand value. 25 | */ 26 | LessThan = 'LESS_THAN', 27 | 28 | /** 29 | * The left-hand value is less than or equal to the right-hand value. 30 | */ 31 | LessThanOrEquals = 'LESS_THAN_OR_EQUALS', 32 | 33 | /** 34 | * The left-hand value is greater than the right-hand value. 35 | */ 36 | GreaterThan = 'GREATER_THAN', 37 | 38 | /** 39 | * The left-hand value is greater than or equal to the right-hand value. 40 | */ 41 | GreaterThanOrEquals = 'GREATER_THAN_OR_EQUALS', 42 | 43 | /** 44 | * The left-hand location is within the specified distance of the right-hand location. 45 | */ 46 | Near = 'NEAR', 47 | 48 | /** 49 | * The records have text fields that contain all specified tokens. 50 | */ 51 | ContainsAllTokens = 'CONTAINS_ALL_TOKENS', 52 | 53 | /** 54 | * The left-hand value is in the right-hand list. 55 | */ 56 | In = 'IN', 57 | 58 | /** 59 | * The left-hand value is not in the right-hand list. 60 | */ 61 | NotIn = 'NOT_IN', 62 | 63 | /** 64 | * The records with text fields contain any of the specified tokens. 65 | */ 66 | ContainsAnyTokens = 'CONTAINS_ANY_TOKENS', 67 | 68 | /** 69 | * The records contain values in a list field. 70 | */ 71 | ListContains = 'LIST_CONTAINS', 72 | 73 | /** 74 | * The records don’t contain the specified values in a list field. 75 | */ 76 | NotListContains = 'NOT_LIST_CONTAINS', 77 | 78 | /** 79 | * The records don’t contain any of the specified values in a list field. 80 | */ 81 | NotListContainsAny = 'NOT_LIST_CONTAINS_ANY', 82 | 83 | /** 84 | * The records with a field that begins with a specified value. 85 | */ 86 | BeginsWith = 'BEGINS_WITH', 87 | 88 | /** 89 | * The records with a field that doesn’t begin with a specified value. 90 | */ 91 | NotBeginsWith = 'NOT_BEGINS_WITH', 92 | 93 | /** 94 | * The records contain a specified value as the first item in a list field. 95 | */ 96 | ListMemberBeginsWith = 'LIST_MEMBER_BEGINS_WITH', 97 | 98 | /** 99 | * The records don’t contain a specified value as the first item in a list field. 100 | */ 101 | NotListMemberBeginsWith = 'NOT_LIST_MEMBER_BEGINS_WITH', 102 | 103 | /** 104 | * The records contain all values in a list field. 105 | */ 106 | ListContainsAll = 'LIST_CONTAINS_ALL', 107 | 108 | /** 109 | * The records don’t contain all specified values in a list field. 110 | */ 111 | NotListContainsAll = 'NOT_LIST_CONTAINS_ALL', 112 | } 113 | 114 | export interface RecordQueryResponse { 115 | /** 116 | * An array containing a result dictionary for each operation in the request. 117 | */ 118 | records: Record[] 119 | 120 | /** 121 | * If included, indicates that there are more results matching this query. To fetch the other results, pass the value of the `continuationMarker` key as the value of the `continuationMarker` key in another query. 122 | */ 123 | continuationMarker?: string 124 | } 125 | 126 | export interface Record { 127 | /** 128 | * The unique name used to identify the record within a zone. The default value is a random UUID. 129 | */ 130 | recordName: string; 131 | 132 | /** 133 | * The name of the record type. This key is required for certain operations if the record doesn’t exist. 134 | */ 135 | recordType: RecordType; 136 | 137 | /** 138 | * The dictionary of key-value pairs whose keys are the record field names and values are field-value dictionaries, described in Record Field Dictionary. The default value is an empty dictionary. 139 | * If the operation is create and this key is omitted or set to null, all fields in a newly created record are set to null. 140 | */ 141 | fields: { [index in string]: RecordField } 142 | 143 | /** 144 | * This field is not documented by Apple. 145 | * @unknown 146 | */ 147 | pluginFields: {}; 148 | 149 | /** 150 | * A string containing the server change token for the record. Use this tag to indicate which version of the record you last fetched. 151 | * This key is required if the operation type is update, replace, or delete. This key is not required if the operation is forceUpdate, forceReplace, or forceDelete. 152 | */ 153 | recordChangeTag: string; 154 | 155 | /** 156 | * The dictionary representation of the date the record was created. 157 | */ 158 | created: Timestamp 159 | 160 | /** 161 | * The dictionary representation of the date the record was modified. 162 | */ 163 | modified: Timestamp 164 | 165 | /** 166 | * A Boolean value indicating whether the record was deleted. If true, it was deleted; otherwise, it was not deleted. 167 | */ 168 | deleted: boolean; 169 | } 170 | 171 | export interface Timestamp { 172 | /** 173 | * The number representing the date/time. 174 | */ 175 | timestamp: number, 176 | 177 | /** 178 | * The record name representing the user. 179 | */ 180 | userRecordName: string, 181 | 182 | /** 183 | * The device where the change occurred. 184 | */ 185 | deviceID: string, 186 | } 187 | 188 | export interface InitParams { 189 | /** 190 | * The name of the iCloud container to use 191 | */ 192 | containerName: string; 193 | 194 | /** 195 | * The ID of the key to use for signing requests 196 | */ 197 | keyId: string; 198 | 199 | /** 200 | * The path to your private key file. 201 | * You can either specify this, or pass the private key using the `privateKeyContents` parameter. 202 | */ 203 | privateKeyPath?: string; 204 | 205 | /** 206 | * Pass the contents of your private key file here. 207 | * You can either specify this, or pass the path to the private key file using the `privateKeyPath` parameter. 208 | */ 209 | privateKeyContents?: Buffer; 210 | 211 | /** 212 | * Set to true to use the production CloudKit environment, 213 | * otherwise the development environment will be used. 214 | */ 215 | shouldUseProduction?: boolean; 216 | 217 | /** 218 | * Which database to use 219 | */ 220 | database?: Database; 221 | } 222 | 223 | export interface ErrorResponse { 224 | /** 225 | * A unique identifier for this error. 226 | */ 227 | uuid: string; 228 | 229 | /** 230 | * The suggested time to wait before trying this operation again. If this key is not set, the operation can’t be retried. 231 | */ 232 | retryAfter?: number 233 | 234 | /** 235 | * A string containing the code for the error that occurred. 236 | */ 237 | serverErrorCode: ErrorType; 238 | 239 | /** 240 | * A string indicating the reason for the error. 241 | */ 242 | reason: string; 243 | } 244 | 245 | export type FieldList = { 246 | [key: string]: FieldType; 247 | } 248 | 249 | export interface FilterBy { 250 | /** 251 | * A string representing the filter comparison operator. 252 | */ 253 | comparator: ComparatorValue; 254 | 255 | /** 256 | * The name of a field belonging to the record type. 257 | */ 258 | fieldName: string; 259 | 260 | /** 261 | * A field-value dictionary, representing the value of the field that you want all fetched records to match. 262 | */ 263 | fieldValue: any; 264 | } 265 | 266 | export interface SortBy { 267 | /** 268 | * The name of a field belonging to the record type. Used to sort the fetched records. 269 | */ 270 | fieldName: string; 271 | 272 | /** 273 | * A Boolean value that indicates whether the fetched records should be sorted in ascending order. If true, the records are sorted in ascending order. If false, the records are sorted in descending order. The default value is true. 274 | */ 275 | ascending?: boolean; 276 | 277 | /** 278 | * A field-value dictionary, that is the reference location to use when sorting. Records are sorted based on their distance to this location. Used only if fieldName is a Location type. 279 | */ 280 | relativeLocation?: RecordField; 281 | } 282 | 283 | export interface QueryDictionary { 284 | /** 285 | * The name of the record type. 286 | */ 287 | recordType: RecordType; 288 | 289 | /** 290 | * An Array of filter dictionaries used to determine whether a record matches the query. 291 | */ 292 | filterBy?: FilterBy[]; 293 | 294 | /** 295 | * An Array of sort descriptor dictionaries that specify how to order the fetched records. 296 | */ 297 | sortBy?: SortBy[]; 298 | } 299 | 300 | export interface QueryRecordOptions { 301 | /** 302 | * A dictionary that identifies a record zone in the database. 303 | */ 304 | zoneID?: string; 305 | 306 | /** 307 | * The maximum number of records to fetch. The default is the maximum number of records in a response that is allowed, described in [Data Size Limits](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/PropertyMetrics.html#//apple_ref/doc/uid/TP40015240-CH23-SW1). 308 | */ 309 | resultsLimit?: number; 310 | 311 | /** 312 | * The query to apply. 313 | */ 314 | query: QueryDictionary; 315 | 316 | /** 317 | * The location of the last batch of results. Use this key when the results of a previous fetch exceeds the maximum. 318 | */ 319 | continuationMarker?: string; 320 | 321 | /** 322 | * An array of strings containing record field names that limits the amount of data returned in this operation. Only the fields specified in the array are returned. The default is null, which fetches all record fields. 323 | */ 324 | desiredKeys?: string[]; 325 | 326 | /** 327 | * A Boolean value determining whether all zones should be searched. This key is ignored if zoneID is non-null. To search all zones, set to true. To search the default zone only, set to false. 328 | */ 329 | zoneWide?: boolean; 330 | 331 | /** 332 | * A Boolean value indicating whether number fields should be represented by strings. The default value is false. 333 | */ 334 | numbersAsStrings?: boolean; 335 | } 336 | 337 | export interface CreateRecordOptions { 338 | /** 339 | * The unique name used to identify the record within a zone. The default value is a random UUID. 340 | */ 341 | recordName?: string 342 | 343 | /** 344 | * The name of the record type. 345 | */ 346 | recordType: RecordType; 347 | 348 | /** 349 | * The dictionary of key-value pairs whose keys are the record field names and values are field-value dictionaries. 350 | * If this key is omitted or set to null, all fields in a newly created record are set to null. 351 | */ 352 | fields?: { [index in string]: RecordField } 353 | } 354 | 355 | export interface UpdateRecordOptions { 356 | /** 357 | * The unique name used to identify the record within a zone. 358 | */ 359 | recordName: string 360 | 361 | /** 362 | * The name of the record type. 363 | */ 364 | recordType?: RecordType; 365 | 366 | /** 367 | * A string containing the server change token for the record. Use this tag to indicate which version of the record you last fetched. 368 | */ 369 | recordChangeTag: string; 370 | 371 | /** 372 | * The dictionary of key-value pairs whose keys are the record field names and values are field-value dictionaries. 373 | */ 374 | fields?: { [ index in string]: RecordField } 375 | } 376 | 377 | export interface DeleteRecordOptions { 378 | /** 379 | * The unique name used to identify the record within a zone. 380 | */ 381 | recordName: string 382 | 383 | /** 384 | * The name of the record type. 385 | */ 386 | recordType?: RecordType; 387 | 388 | /** 389 | * A string containing the server change token for the record. Use this tag to indicate which version of the record you last fetched. 390 | */ 391 | recordChangeTag: string; 392 | } 393 | 394 | /** 395 | * The name of the record type 396 | */ 397 | export type RecordType = string 398 | 399 | enum DeleteAction { 400 | /** 401 | * No action when a referenced record is deleted. 402 | */ 403 | None = "NONE", 404 | 405 | /** 406 | * Deletes a source record when the target record is deleted. 407 | */ 408 | DeleteSelf = "DELETE_SELF", 409 | 410 | /** 411 | * Deletes a target record only after all source records are deleted. Verifies that the target record exists before creating this type of reference. If it doesn’t exist, creating the reference fails. 412 | */ 413 | Validate = "VALIDATE" 414 | } 415 | 416 | export interface ReferenceField { 417 | /** 418 | * The unique name used to identify the record within a zone. 419 | */ 420 | recordName: string; 421 | 422 | /** 423 | * The delete action for the reference object 424 | */ 425 | action?: DeleteAction; 426 | } 427 | 428 | export interface RecordField { 429 | value: any; 430 | type?: string 431 | } 432 | 433 | export enum FieldType { 434 | String = "String", 435 | Int64 = "Int64", 436 | Reference = "Reference", 437 | Location = "Location", 438 | Double = "Double", 439 | DateTime = "DateTime", 440 | Bytes = "Bytes", 441 | } 442 | 443 | export interface ISignService { 444 | signRequest(payload: string, requestPath: string): { signature: string, dateString: string } 445 | getKeyId(): string 446 | } 447 | 448 | export type Environment = "development" | "production" 449 | export type Database = "public" | "private" | "shared" 450 | 451 | export interface UrlBuilderInitParams { 452 | /** 453 | * A unique identifier for the app’s container. The container ID should begin with `iCloud.`. 454 | */ 455 | containerName: string; 456 | 457 | /** 458 | * The version of the app’s container. Pass development to use the environment that is not accessible by apps available on the store. Pass production to use the environment that is accessible by development apps and apps available on the store. 459 | */ 460 | environment: Environment 461 | 462 | /** 463 | * The database to store the data within the container. 464 | */ 465 | database: Database; 466 | } 467 | 468 | export enum ErrorType { 469 | /** 470 | * You don’t have permission to access the endpoint, record, zone, or database. 471 | */ 472 | AccessDenied = "ACCESS_DENIED", 473 | 474 | /** 475 | * An atomic batch operation failed. 476 | */ 477 | AtomicError = "ATOMIC_ERROR", 478 | 479 | /** 480 | * Authentication was rejected. 481 | */ 482 | AuthenticationFailed = "AUTHENTICATION_FAILED", 483 | 484 | /** 485 | * The request requires authentication but none was provided. 486 | */ 487 | AuthenticationRequired = "AUTHENTICATION_REQUIRED", 488 | 489 | /** 490 | * The request was not valid. 491 | */ 492 | BadRequest = "BAD_REQUEST", 493 | 494 | /** 495 | * The recordChangeTag value expired. (Retry the request with the latest tag.) 496 | */ 497 | Conflict = "CONFLICT", 498 | 499 | /** 500 | * The resource that you attempted to create already exists. 501 | */ 502 | Exists = "EXISTS", 503 | 504 | /** 505 | * An internal error occurred. 506 | */ 507 | InternalError = "INTERNAL_ERROR", 508 | 509 | /** 510 | * The resource was not found. 511 | */ 512 | NotFound = "NOT_FOUND", 513 | 514 | /** 515 | * If accessing the public database, you exceeded the app’s quota. If accessing the private database, you exceeded the user’s iCloud quota. 516 | */ 517 | QuotaExceeded = "QUOTA_EXCEEDED", 518 | 519 | /** 520 | * The request was throttled. Try the request again later. 521 | */ 522 | Throttled = "THROTTLED", 523 | 524 | /** 525 | * An internal error occurred. Try the request again. 526 | */ 527 | TryAgainLater = "TRY_AGAIN_LATER", 528 | 529 | /** 530 | * The request violates a validating reference constraint. 531 | */ 532 | ValidatingReferenceError = "VALIDATING_REFERENCE_ERROR", 533 | 534 | /** 535 | * The zone specified in the request was not found. 536 | */ 537 | ZoneNotFound = "ZONE_NOT_FOUND", 538 | } 539 | -------------------------------------------------------------------------------- /src/url.ts: -------------------------------------------------------------------------------- 1 | import { Database, Environment, UrlBuilderInitParams } from "./types" 2 | 3 | export class UrlBuilder { 4 | protected readonly versionNumber = 1 5 | protected readonly containerName: string 6 | protected readonly environment: Environment 7 | protected readonly database: Database 8 | 9 | constructor(initParams: UrlBuilderInitParams) { 10 | this.containerName = initParams.containerName 11 | this.environment = initParams.environment 12 | this.database = initParams.database || "public" 13 | } 14 | 15 | protected getBasePath() { 16 | return [ 17 | `/database`, 18 | this.versionNumber, 19 | this.containerName, 20 | this.environment, 21 | this.database 22 | ].join('/') 23 | } 24 | 25 | /** Records */ 26 | 27 | public getModifyRecordsPath() { 28 | return `${this.getBasePath()}/records/modify` 29 | } 30 | 31 | public getQueryRecordsPath() { 32 | return `${this.getBasePath()}/records/query` 33 | } 34 | 35 | public getLookupRecordsPath() { 36 | return `${this.getBasePath()}/records/lookup` 37 | } 38 | 39 | public getResolveRecordsPath() { 40 | return `${this.getBasePath()}/records/resolve` 41 | } 42 | 43 | public getShareAcceptPath() { 44 | return `${this.getBasePath()}/records/shares/accept` 45 | } 46 | 47 | /** Assets */ 48 | public getUploadAssetsPath() { 49 | return `${this.getBasePath()}/assets/upload` 50 | } 51 | } -------------------------------------------------------------------------------- /tests/url.test.ts: -------------------------------------------------------------------------------- 1 | import { UrlBuilder } from "../src/url" 2 | 3 | const urlBuilder = new UrlBuilder({ 4 | containerName: 'iCloud.testContainer', 5 | environment: 'development', 6 | database: 'public' 7 | }) 8 | 9 | test('it gets modify records path', () => { 10 | expect(urlBuilder.getModifyRecordsPath()).toBe('/database/1/iCloud.testContainer/development/public/records/modify') 11 | }) 12 | 13 | test('it gets query records path', () => { 14 | expect(urlBuilder.getQueryRecordsPath()).toBe('/database/1/iCloud.testContainer/development/public/records/query') 15 | }) 16 | 17 | test('it gets lookup records path', () => { 18 | expect(urlBuilder.getLookupRecordsPath()).toBe('/database/1/iCloud.testContainer/development/public/records/lookup') 19 | }) 20 | 21 | test('it gets resolve records path', () => { 22 | expect(urlBuilder.getResolveRecordsPath()).toBe('/database/1/iCloud.testContainer/development/public/records/resolve') 23 | }) 24 | 25 | test('it gets share accept path', () => { 26 | expect(urlBuilder.getShareAcceptPath()).toBe('/database/1/iCloud.testContainer/development/public/records/shares/accept') 27 | }) 28 | 29 | test('it gets upload assets path', () => { 30 | expect(urlBuilder.getUploadAssetsPath()).toBe('/database/1/iCloud.testContainer/development/public/assets/upload') 31 | }) 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "resolveJsonModule": true, 7 | "declaration": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "outDir": "dist", 12 | }, 13 | "include": [ 14 | "src" 15 | ], 16 | "exclude": [ 17 | "node_modules", 18 | "examples", 19 | "secrets.ts" 20 | ], 21 | } 22 | --------------------------------------------------------------------------------