diff --git a/package.json b/package.json index 3773e3dce..ca6034153 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", @@ -132,6 +133,7 @@ "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 07896d59f..b68760610 100644 --- a/src/index.ts +++ b/src/index.ts @@ -373,22 +373,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 1c0139c59..30e355617 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 = () => { @@ -349,3 +352,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..ae3fc757f 100644 --- a/tests/integration/models/organization.spec.ts +++ b/tests/integration/models/organization.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import parallel from 'mocha.parallel'; +import * as superagent from 'superagent'; import { balena, credentials, @@ -98,6 +99,27 @@ describe('Organization model', function () { .that.is.not.equal(ctx.newOrg1.handle); ctx.newOrg2 = org; }); + + it('should be able to create an organization with a logo', async function () { + const org = await balena.models.organization.create({ + name: 'org-with-logo', + logo_image: new balena.utils.BalenaWebResourceFile( + [Buffer.from('this is a test\n')], + 'orglogo.png', + ), + }); + + const fetchedOrg = await balena.models.organization.get(org.id, { + $select: ['id', 'logo_image'], + }); + expect(fetchedOrg) + .to.have.nested.property('logo_image.href') + .that.is.a('string'); + + const res = await superagent.get(fetchedOrg.logo_image.href); + expect(res.status).to.equal(200); + expect(res.headers['content-length']).to.equal('15'); + }); }); }); @@ -108,7 +130,7 @@ describe('Organization model', function () { $orderby: 'id asc', }); expect(orgs).to.be.an('array'); - expect(orgs).to.have.lengthOf(3); + expect(orgs).to.have.lengthOf(4); const [org1, org2, org3] = orgs; expect(org1).to.deep.match(ctx.userInitialOrg); expect(org2).to.deep.match(ctx.newOrg1); 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 =