Skip to content

Commit

Permalink
Merge pull request #4271 from Shopify/check-org-flags-on-generate
Browse files Browse the repository at this point in the history
Check organization beta flags for gated template specifications
  • Loading branch information
amcaplan committed Aug 8, 2024
2 parents 3148623 + fc2858d commit 3b2029a
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 40 deletions.
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import {AppManagementClient, GatedExtensionTemplate, allowedTemplates, diffAppModules} from './app-management-client.js'
import {
AppManagementClient,
GatedExtensionTemplate,
allowedTemplates,
diffAppModules,
encodedGidFromId,
} from './app-management-client.js'
import {AppModule} from './app-management-client/graphql/app-version-by-id.js'
import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js'
import {testUIExtension, testRemoteExtensionTemplates, testOrganizationApp} from '../../models/app/app.test-data.js'
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import {describe, expect, test, vi} from 'vitest'
import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version'
import {fetch} from '@shopify/cli-kit/node/http'
import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform'

vi.mock('@shopify/cli-kit/node/http')
vi.mock('@shopify/cli-kit/node/api/business-platform')

const extensionA = await testUIExtension({uid: 'extension-a-uuid'})
const extensionB = await testUIExtension({uid: 'extension-b-uuid'})
const extensionC = await testUIExtension({uid: 'extension-c-uuid'})

const templateWithoutRules: GatedExtensionTemplate = testRemoteExtensionTemplates[0]!
const allowedTemplate: GatedExtensionTemplate = {
...testRemoteExtensionTemplates[1]!,
organizationBetaFlags: ['allowedFlag'],
minimumCliVersion: '1.0.0',
}
const templateDisallowedByCliVersion: GatedExtensionTemplate = {
...testRemoteExtensionTemplates[2]!,
organizationBetaFlags: ['allowedFlag'],
// minimum CLI version is higher than the current CLI version
minimumCliVersion: `1${CLI_KIT_VERSION}`,
}
const templateDisallowedByBetaFlag: GatedExtensionTemplate = {
...testRemoteExtensionTemplates[3]!,
// organization beta flag is not allowed
organizationBetaFlags: ['notAllowedFlag'],
minimumCliVersion: '1.0.0',
}

function moduleFromExtension(extension: ExtensionInstance): AppModule {
return {
uuid: extension.uid,
Expand Down Expand Up @@ -49,21 +77,70 @@ describe('diffAppModules', () => {
describe('templateSpecifications', () => {
test('returns the templates with sortPriority to enforce order', async () => {
// Given
const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(testRemoteExtensionTemplates))
const orgApp = testOrganizationApp()
const templates: GatedExtensionTemplate[] = [templateWithoutRules, allowedTemplate]
const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(templates))
vi.mocked(fetch).mockImplementation(mockedFetch)
const mockedFetchFlagsResponse: OrganizationBetaFlagsQuerySchema = {
organization: {
id: encodedGidFromId(orgApp.organizationId),
flag_allowedFlag: true,
},
}
vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedFetchFlagsResponse)

// When
const client = new AppManagementClient()
const got = await client.templateSpecifications(testOrganizationApp())
client.businessPlatformToken = () => Promise.resolve('business-platform-token')
const got = await client.templateSpecifications(orgApp)
const gotLabels = got.map((template) => template.name)
const gotSortPriorities = got.map((template) => template.sortPriority)

// Then
expect(got.length).toEqual(testRemoteExtensionTemplates.length)
expect(gotLabels).toEqual(testRemoteExtensionTemplates.map((template) => template.name))
expect(got.length).toEqual(templates.length)
expect(gotLabels).toEqual(templates.map((template) => template.name))
expect(gotSortPriorities).toEqual(gotSortPriorities.sort())
})

test('returns only allowed templates', async () => {
// Given
const orgApp = testOrganizationApp()
const templates: GatedExtensionTemplate[] = [templateWithoutRules, allowedTemplate, templateDisallowedByBetaFlag]
const mockedFetch = vi.fn().mockResolvedValueOnce(Response.json(templates))
vi.mocked(fetch).mockImplementation(mockedFetch)
const mockedFetchFlagsResponse: OrganizationBetaFlagsQuerySchema = {
organization: {
id: encodedGidFromId(orgApp.organizationId),
flag_allowedFlag: true,
flag_notAllowedFlag: false,
},
}
vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValueOnce(mockedFetchFlagsResponse)

// When
const client = new AppManagementClient()
client.businessPlatformToken = () => Promise.resolve('business-platform-token')
const got = await client.templateSpecifications(orgApp)
const gotLabels = got.map((template) => template.name)

// Then
expect(vi.mocked(businessPlatformOrganizationsRequest)).toHaveBeenCalledWith(
`
query OrganizationBetaFlags($organizationId: OrganizationID!) {
organization(organizationId: $organizationId) {
id
flag_allowedFlag: hasFeatureFlag(handle: "allowedFlag")
flag_notAllowedFlag: hasFeatureFlag(handle: "notAllowedFlag")
}
}`,
'business-platform-token',
orgApp.organizationId,
{organizationId: encodedGidFromId(orgApp.organizationId)},
)
const expectedAllowedTemplates = [templateWithoutRules, allowedTemplate]
expect(gotLabels).toEqual(expectedAllowedTemplates.map((template) => template.name))
})

test('fails with an error message when fetching the specifications list fails', async () => {
// Given
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
Expand All @@ -80,24 +157,6 @@ describe('templateSpecifications', () => {
describe('allowedTemplates', () => {
test('filters templates by betas', async () => {
// Given
const templateWithoutRules: GatedExtensionTemplate = testRemoteExtensionTemplates[0]!
const allowedTemplate: GatedExtensionTemplate = {
...testRemoteExtensionTemplates[1]!,
organizationBetaFlags: ['allowedFlag'],
minimumCliVersion: '1.0.0',
}
const templateDisallowedByCliVersion: GatedExtensionTemplate = {
...testRemoteExtensionTemplates[2]!,
organizationBetaFlags: ['allowedFlag'],
// minimum CLI version is higher than the current CLI version
minimumCliVersion: `1${CLI_KIT_VERSION}`,
}
const templateDisallowedByBetaFlag: GatedExtensionTemplate = {
...testRemoteExtensionTemplates[3]!,
// organization beta flag is not allowed
organizationBetaFlags: ['notAllowedFlag'],
minimumCliVersion: '1.0.0',
}
const templates: GatedExtensionTemplate[] = [
templateWithoutRules,
allowedTemplate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ import {
AppVersionByIdQueryVariables,
AppModule as AppModuleReturnType,
} from './app-management-client/graphql/app-version-by-id.js'
import {
OrganizationBetaFlagsQuerySchema,
OrganizationBetaFlagsQueryVariables,
organizationBetaFlagsQuery,
} from './app-management-client/graphql/organization_beta_flags.js'
import {RemoteSpecification} from '../../api/graphql/extension_specifications.js'
import {
DeveloperPlatformClient,
Expand Down Expand Up @@ -127,6 +132,7 @@ import {appManagementRequest} from '@shopify/cli-kit/node/api/app-management'
import {appDevRequest} from '@shopify/cli-kit/node/api/app-dev'
import {
businessPlatformOrganizationsRequest,
businessPlatformOrganizationsRequestDoc,
businessPlatformRequest,
businessPlatformRequestDoc,
} from '@shopify/cli-kit/node/api/business-platform'
Expand Down Expand Up @@ -381,7 +387,7 @@ export class AppManagementClient implements DeveloperPlatformClient {
// partners-client and app-management-client. Since we need transferDisabled and convertableToPartnerTest values
// from the Partners OrganizationStore schema, we will return this type for now
async devStoresForOrg(orgId: string): Promise<OrganizationStore[]> {
const storesResult = await businessPlatformOrganizationsRequest<ListAppDevStoresQuery>(
const storesResult = await businessPlatformOrganizationsRequestDoc<ListAppDevStoresQuery>(
ListAppDevStores,
await this.businessPlatformToken(),
orgId,
Expand Down Expand Up @@ -731,7 +737,7 @@ export class AppManagementClient implements DeveloperPlatformClient {
// from the Partners FindByStoreDomainSchema, we will return this type for now
async storeByDomain(orgId: string, shopDomain: string): Promise<FindStoreByDomainSchema> {
const queryVariables: FetchDevStoreByDomainQueryVariables = {domain: shopDomain}
const storesResult = await businessPlatformOrganizationsRequest(
const storesResult = await businessPlatformOrganizationsRequestDoc(
FetchDevStoreByDomain,
await this.businessPlatformToken(),
orgId,
Expand Down Expand Up @@ -865,15 +871,21 @@ export class AppManagementClient implements DeveloperPlatformClient {
}

private async organizationBetaFlags(
_organizationId: string,
organizationId: string,
allBetaFlags: string[],
): Promise<{[key: string]: boolean}> {
// For now, stub everything as false
const stub: {[flag: string]: boolean} = {}
): Promise<{[flag: (typeof allBetaFlags)[number]]: boolean}> {
const variables: OrganizationBetaFlagsQueryVariables = {organizationId: encodedGidFromId(organizationId)}
const flagsResult = await businessPlatformOrganizationsRequest<OrganizationBetaFlagsQuerySchema>(
organizationBetaFlagsQuery(allBetaFlags),
await this.businessPlatformToken(),
organizationId,
variables,
)
const result: {[flag: (typeof allBetaFlags)[number]]: boolean} = {}
allBetaFlags.forEach((flag) => {
stub[flag] = false
result[flag] = Boolean(flagsResult.organization[`flag_${flag}`])
})
return stub
return result
}
}

Expand Down Expand Up @@ -926,7 +938,7 @@ function createAppVars(name: string, isLaunchable = true, scopesArray?: string[]
// just the integer portion of that ID. These functions convert between the two.

// 1234 => gid://organization/Organization/1234 => base64
function encodedGidFromId(id: string): string {
export function encodedGidFromId(id: string): string {
const gid = `gid://organization/Organization/${id}`
return Buffer.from(gid).toString('base64')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {gql} from 'graphql-request'

export function organizationBetaFlagsQuery(flags: string[]): string {
return gql`
query OrganizationBetaFlags($organizationId: OrganizationID!) {
organization(organizationId: $organizationId) {
id
${flags.map((flag) => `flag_${flag}: hasFeatureFlag(handle: "${flag}")`).join('\n ')}
}
}`
}

export interface OrganizationBetaFlagsQueryVariables {
organizationId: string
}

export interface OrganizationBetaFlagsQuerySchema {
organization: {
id: string
[flag: `flag_${string}`]: boolean
}
}
50 changes: 42 additions & 8 deletions packages/cli-kit/src/public/node/api/business-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,30 +61,64 @@ export async function businessPlatformRequestDoc<TResult, TVariables extends Var
})
}

/**
* Sets up the request to the Business Platform Organizations API.
*
* @param token - Business Platform token.
* @param organizationId - Organization ID as a numeric (non-GID) value.
*/
async function setupOrganizationsRequest(token: string, organizationId: string) {
const api = 'BusinessPlatform'
const fqdn = await businessPlatformFqdn()
const url = `https://${fqdn}/organizations/api/unstable/organization/${organizationId}/graphql`
return {
token,
api,
url,
responseOptions: {onResponse: handleDeprecations},
}
}

/**
* Executes a GraphQL query against the Business Platform Organizations API.
*
* @param query - GraphQL query to execute.
* @param token - Business Platform token.
* @param organizationId - Organization ID as a numeric (non-GID) value.
* @param variables - GraphQL variables to pass to the query.
* @returns The response of the query of generic type <T>.
*/
export async function businessPlatformOrganizationsRequest<T>(
query: string,
token: string,
organizationId: string,
variables?: GraphQLVariables,
): Promise<T> {
return graphqlRequest<T>({
query,
...(await setupOrganizationsRequest(token, organizationId)),
variables,
})
}

/**
* Executes a GraphQL query against the Business Platform Organizations API. Uses typed documents.
*
* @param query - GraphQL query to execute.
* @param token - Business Platform token.
* @param organizationId - Organization ID as a numeric value.
* @param variables - GraphQL variables to pass to the query.
* @returns The response of the query of generic type <T>.
*/
export async function businessPlatformOrganizationsRequest<TResult>(
export async function businessPlatformOrganizationsRequestDoc<TResult>(
query: TypedDocumentNode<TResult, GraphQLVariables> | TypedDocumentNode<TResult, Exact<{[key: string]: never}>>,
token: string,
organizationId: string,
variables?: GraphQLVariables,
): Promise<TResult> {
const api = 'BusinessPlatform'
const fqdn = await businessPlatformFqdn()
const url = `https://${fqdn}/organizations/api/unstable/organization/${organizationId}/graphql`
return graphqlRequestDoc({
query,
api,
url,
token,
...(await setupOrganizationsRequest(token, organizationId)),
variables,
responseOptions: {onResponse: handleDeprecations},
})
}

0 comments on commit 3b2029a

Please sign in to comment.