@usekana/client-kana-js
Version:
Kana frontend JavaScript client
226 lines (195 loc) • 6.86 kB
text/typescript
import { EmptyArgumentError, NetworkError, RequestError } from './errors';
import { createGQLSdk, GQLSdk } from './gqlClient';
import { CurrentGroupQuery, FeatureType } from './graphql/generated/gqlTypes';
import {
KanaPublicApiKeyClientConfig,
KanaGroupClientConfig,
KanaGroupClientFullConfig,
KanaGroupTokenClientConfig,
} from './KanaGroupClientConfig';
import { request } from './request';
import { Consumption, Entitlement, Feature, Package, Group } from './types';
import { unique } from './utils/unique';
const maxRetries = 3;
const defaultConfigOptions = {
endpoint: 'https://client-api.usekana.com/graphql',
version: '0.1',
retry: (error: Error, retryNumber: number) => {
return error instanceof NetworkError && retryNumber < maxRetries;
},
};
export class KanaGroupClient {
public readonly config: KanaGroupClientFullConfig;
private readonly gqlSdk: GQLSdk;
private _groupCached = false;
private _group?: Group = undefined;
private _groupSubscribedPackages: Package[] = [];
private _groupSubscribedFeatures: Feature[] = [];
private _groupFeatureConsumptions: Map<string, Consumption> = new Map();
constructor(config: KanaGroupClientConfig) {
if (!config) {
throw new EmptyArgumentError('config');
}
if ((config as KanaGroupTokenClientConfig).groupToken) {
this.config = {
...defaultConfigOptions,
...(config as KanaGroupTokenClientConfig),
type: 'GroupToken',
};
} else if ((config as KanaPublicApiKeyClientConfig).apiKey) {
if (!(config as KanaPublicApiKeyClientConfig).groupId) {
throw new Error(
'Kana config error, "groupId" is required when "apiKey" is used.',
);
}
this.config = {
...defaultConfigOptions,
...(config as KanaPublicApiKeyClientConfig),
type: 'PublicApiKey',
};
} else {
throw new Error(
'Kana config error, "groupToken" or "apiKey" is required for client initialization.',
);
}
this.gqlSdk = createGQLSdk(this.config);
}
async resetCache() {
await request<void, RequestError>(this.config, async () => {
const groupCache = await this.gqlSdk.CurrentGroup();
this.updateGroupFields(groupCache);
});
}
async getGroup() {
return request<Group | undefined, RequestError>(this.config, async () => {
await this.initGroupCache();
return this._group;
});
}
async getSubscribedPackages() {
return request<Package[], RequestError>(this.config, async () => {
await this.initGroupCache();
return this._groupSubscribedPackages;
});
}
async getSubscribedFeatures() {
return request<Feature[], RequestError>(this.config, async () => {
await this.initGroupCache();
return this._groupSubscribedFeatures;
});
}
async canUseFeature(featureId: string, delta?: number) {
return request<Entitlement, RequestError>(this.config, async () => {
await this.initGroupCache();
const feature = this._groupSubscribedFeatures.find(
(f) => f.id === featureId,
);
if (feature) {
if (feature.type === FeatureType.Binary) {
return {
access: true,
reason:
'The group has subscribed to a package with this binary feature.',
};
} else if (feature.type === FeatureType.Consumable) {
const consumption = this._groupFeatureConsumptions.get(featureId);
if (consumption) {
const calculatedUsed = delta
? delta - 1 + consumption.used
: consumption.used;
const access =
// unlimited budget or overage is allowed
consumption.budget === null || consumption.overageEnabled
? true
: calculatedUsed < consumption.budget;
return {
access,
consumption,
reason: access
? 'The group has a subcription to a package with this consumable feature and either has an allowance remaining or overage is enabled.'
: 'The group has no remanining allowance of this feature and overage is not enabled.',
};
}
}
}
return {
access: false,
reason: 'The group has no active subscription to the feature.',
};
});
}
private async initGroupCache() {
if (!this._groupCached) {
const cache = await this.gqlSdk.CurrentGroup();
this.updateGroupFields(cache);
this._groupCached = true;
}
}
private updateGroupFields(cache: CurrentGroupQuery) {
const currentGroup = cache.currentGroup;
this._group = {
id: currentGroup.id,
email: currentGroup.email,
name: currentGroup.name,
metadata: currentGroup.metadata,
};
this._groupSubscribedPackages = unique(
currentGroup.subscriptions.map((sub) => ({
id: sub.package.id,
name: sub.package.name,
isAddon: sub.package.isAddon,
metadata: sub.package.metadata,
})),
(p) => p.id,
);
this._groupSubscribedFeatures = unique(
currentGroup.subscriptions.flatMap((sub) => sub.package.features),
(f) => f.id,
).map((f) => ({
id: f.id,
name: f.name,
type: f.type,
metadata: f.metadata,
unitLabel: f.unitLabel,
unitLabelPlural: f.unitLabelPlural,
}));
const featureConsumptions = currentGroup.subscriptions
.flatMap((sub) =>
sub.package.features.flatMap((f) => ({
feature: f,
consumption: f.consumption,
})),
)
.reduce((agg, fetCon) => {
const item = agg[fetCon.feature.id];
const consumptions = item?.consumptions || [];
const consumption = fetCon.consumption as
| Consumption
| undefined
| null;
if (consumption) {
consumptions.push(consumption);
}
agg[fetCon.feature.id] = {
feature: fetCon.feature,
consumptions: consumptions,
};
return agg;
}, {} as Record<string, { feature: Feature; consumptions: Consumption[] }>);
this._groupFeatureConsumptions = new Map();
for (const featureId of Object.keys(featureConsumptions)) {
const fetCons = featureConsumptions[featureId];
const aggConsumption = {
budget: fetCons.consumptions.find((c) => c.budget === null)
? null
: fetCons.consumptions.reduce((agg, c) => agg + (c.budget ?? 0), 0),
used: fetCons.consumptions.reduce((agg, c) => agg + c.used, 0),
overageEnabled: fetCons.consumptions.reduce(
(agg, c) => agg || c.overageEnabled,
false,
),
};
this._groupFeatureConsumptions.set(featureId, aggConsumption);
}
}
}