Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check organization beta flags for gated template specifications #4271

Merged
merged 3 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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},
gonzaloriestra marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* 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>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused. Why do we need two functions to run the query? Is this related to the PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first function above uses classic manually typed schemas, while this function uses auto-generated types.

I do eventually need to learn more about the auto-generated stuff, but I think for this PR it's not relevant, as the GraphQL string is generated at runtime.

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},
})
}
Loading