diff --git a/package.json b/package.json index b6700ec3a..36d267cd8 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/chai-as-promised": "^7.1.4", "@types/lodash": "^4.14.195", "@types/memoizee": "^0.4.7", + "@types/mime": "^3.0.3", "@types/mocha": "^10.0.1", "@types/ndjson": "^2.0.0", "@types/sinon": "^10.0.6", @@ -124,14 +125,15 @@ "balena-auth": "^5.1.0", "balena-errors": "^4.8.0", "balena-hup-action-utils": "~5.0.0", - "balena-register-device": "^9.0.1", - "balena-request": "^13.0.0", + "balena-register-device": "9.0.2-build-oj-test-deploy-with-new-balena-request-97726e6112f1bc6a1f3785f7edac31fd44c23caf-1", + "balena-request": "13.1.0-build-otaviojacobi-does-multipart-form-when-blob-is-present-11b0087dafe086f4d8ff565f8fffa6aa924a909b-1", "balena-semver": "^2.3.0", "balena-settings-client": "^5.0.0", "date-fns": "^2.29.3", "handlebars": "^4.7.7", "lodash": "^4.17.21", "memoizee": "^0.4.15", + "mime": "^3.0.0", "ndjson": "^2.0.0", "p-throttle": "^4.1.1", "pinejs-client-core": "^6.12.0", diff --git a/src/index.ts b/src/index.ts index 04c3ec06e..56b9469de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -368,22 +368,29 @@ export const getSdk = function ($opts?: SdkOptions) { * @memberof balena * * @description - * The utils instance used internally. This should not be necessary - * in normal usage, but can be useful to handle some specific cases. + * The utils instance offers some convenient features for clients. * * @example * balena.utils.mergePineOptions( * { $expand: { device: { $select: ['id'] } } }, * { $expand: { device: { $select: ['name'] } } }, * ); + * + * @example + * // Creating a new WebResourceFile in case 'File' API is not available. + * new balena.utils.BalenaWebResourceFile( + * [fs.readFileSync('./file.tgz')], + * 'file.tgz' + * ); */ Object.defineProperty(sdk, 'utils', { enumerable: true, configurable: true, get() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { mergePineOptions } = require('./util') as typeof import('./util'); - return { mergePineOptions }; + const { mergePineOptions, BalenaWebResourceFile } = + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('./util') as typeof import('./util'); + return { mergePineOptions, BalenaWebResourceFile }; }, }); diff --git a/src/models/organization.ts b/src/models/organization.ts index 2a204935b..71b22c354 100644 --- a/src/models/organization.ts +++ b/src/models/organization.ts @@ -66,6 +66,31 @@ const getOrganizationModel = function ( * balena.models.organization.create({ name:'MyOrganization' }).then(function(organization) { * console.log(organization); * }); + * + * @example + * balena.models.organization.create({ + * name:'MyOrganization', + * logo_image: new balena.utils.BalenaWebResourceFile( + * [fs.readFileSync('./img.jpeg')], + * 'img.jpeg' + * ); + * }) + * .then(function(organization) { + * console.log(organization); + * }); + * + * @example + * balena.models.organization.create({ + * name:'MyOrganization', + * // Only in case File API is avaialable (most browsers and Node 20+) + * logo_image: new File( + * imageContent, + * 'img.jpeg' + * ); + * }) + * .then(function(organization) { + * console.log(organization); + * }); */ const create = function ( organization: BalenaSdk.PineSubmitBody, diff --git a/src/types/models.ts b/src/types/models.ts index aa90f2ae7..5e9985f6c 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -6,6 +6,7 @@ import type { OptionalNavigationResource, ReverseNavigationResource, ConceptTypeNavigationResource, + WebResource, } from '../../typings/pinejs-client-core'; import type { AnyObject } from '../../typings/utils'; @@ -84,6 +85,7 @@ export interface Organization { handle: string; has_past_due_invoice_since__date: string | null; is_frozen: boolean; + logo_image: WebResource; application: ReverseNavigationResource; /** includes__organization_membership */ diff --git a/src/util/index.ts b/src/util/index.ts index 003091257..1a9d27c93 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,9 +1,12 @@ import * as errors from 'balena-errors'; import type * as Pine from '../../typings/pinejs-client-core'; import type { IfDefined } from '../../typings/utils'; +import type { WebResourceFile } from 'balena-request'; +import * as mime from 'mime'; export interface BalenaUtils { mergePineOptions: typeof mergePineOptions; + BalenaWebResourceFile: typeof BalenaWebResourceFile; } export const notImplemented = () => { @@ -344,3 +347,15 @@ export const limitedMap = ( } }); }; + +export class BalenaWebResourceFile extends Blob implements WebResourceFile { + public name: string; + constructor(blobParts: BlobPart[], name: string, options?: BlobPropertyBag) { + const opts = { + ...options, + type: options?.type ?? mime.getType(name) ?? undefined, + }; + super(blobParts, opts); + this.name = name; + } +} diff --git a/tests/integration/models/organization.spec.ts b/tests/integration/models/organization.spec.ts index b7d313539..7bdc871bb 100644 --- a/tests/integration/models/organization.spec.ts +++ b/tests/integration/models/organization.spec.ts @@ -5,6 +5,7 @@ import { credentials, givenLoggedInUser, organizationRetrievalFields, + IS_BROWSER, } from '../setup'; import { timeSuite } from '../../util'; @@ -98,6 +99,26 @@ describe('Organization model', function () { .that.is.not.equal(ctx.newOrg1.handle); ctx.newOrg2 = org; }); + + it.skip('should be able to create an organization with a logo', async function () { + if (IS_BROWSER) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require('fs') as typeof import('fs'); + const org = await balena.models.organization.create({ + name: 'org-with-logo', + logo_image: new balena.utils.BalenaWebResourceFile( + [fs.readFileSync('orglogo.png')], + 'orglogo.png', + ), + }); + + const fetchedOrg = await balena.models.organization.get(org.id, { + $select: ['id', 'logo_image'], + }); + expect(fetchedOrg).to.have.property('logo_image').that.is.a('string'); + }); }); }); diff --git a/typings/pinejs-client-core.d.ts b/typings/pinejs-client-core.d.ts index a2c366159..f2663cb82 100644 --- a/typings/pinejs-client-core.d.ts +++ b/typings/pinejs-client-core.d.ts @@ -1,3 +1,4 @@ +import type { WebResourceFile } from 'balena-request'; import type { AnyObject, PropsAssignableWithType, @@ -143,6 +144,14 @@ export type PostResult = SelectResultObject< Exclude, PropsOfType>> >; +export type WebResource = { + filename: string; + href: string; + content_type?: string; + content_disposition?: string; + size?: number; +}; + // based on https://github.com/balena-io/pinejs-client-js/blob/master/core.d.ts type RawFilter = @@ -379,10 +388,11 @@ export type ODataOptionsStrict = Omit< export type ODataOptionsWithFilter = ODataOptions & Required, '$filter'>>; +export type ReplaceWebResource = K extends WebResource ? WebResourceFile : K; export type SubmitBody = { [k in keyof T]?: T[k] extends AssociatedResource ? number | null - : T[k]; + : ReplaceWebResource; }; type BaseResourceId =