|
1 | 1 | import axios from 'axios';
|
2 |
| -import { cfGetInstanceKeyParameters } from '@sap/cf-tools'; |
| 2 | +import { |
| 3 | + apiCreateServiceInstance, |
| 4 | + apiGetInstanceCredentials, |
| 5 | + apiGetServicesInstancesFilteredByType, |
| 6 | + cfGetInstanceKeyParameters, |
| 7 | + cfGetTarget |
| 8 | +} from '@sap/cf-tools'; |
3 | 9 | import type { Logger } from '@sap-ux/logger';
|
4 | 10 | import { ENV } from './app-studio.env';
|
5 |
| -import { isS4HC, type Destination, type ListDestinationOpts } from './destination'; |
| 11 | +import { |
| 12 | + Authentication, |
| 13 | + type Destination, |
| 14 | + isS4HC, |
| 15 | + type ListDestinationOpts, |
| 16 | + type CloudFoundryServiceInfo, |
| 17 | + OAuthUrlType, |
| 18 | + DestinationProxyType, |
| 19 | + DestinationType |
| 20 | +} from './destination'; |
| 21 | +import type { ServiceInfo } from './service-info'; |
| 22 | +import { destinations as destinationAPI } from '@sap/bas-sdk'; |
| 23 | + |
| 24 | +/** |
| 25 | + * ABAP Cloud destination instance name. |
| 26 | + */ |
| 27 | +const DESTINATION_INSTANCE_NAME: string = 'abap-cloud-destination-instance'; |
6 | 28 |
|
7 | 29 | /**
|
8 | 30 | * HTTP header that is to be used for encoded credentials when communicating with a destination service instance.
|
9 | 31 | */
|
10 | 32 | export const BAS_DEST_INSTANCE_CRED_HEADER = 'bas-destination-instance-cred';
|
11 | 33 |
|
12 | 34 | /**
|
13 |
| - * Check if this is exectued in SAP Business Application Studio. |
| 35 | + * Check if this is executed in SAP Business Application Studio. |
14 | 36 | *
|
15 | 37 | * @returns true if yes
|
16 | 38 | */
|
@@ -81,7 +103,7 @@ export type Destinations = { [name: string]: Destination };
|
81 | 103 | * Helper function to strip `-api` from the host name.
|
82 | 104 | *
|
83 | 105 | * @param host -
|
84 |
| - * @returns |
| 106 | + * @returns an updated string value, with `-api` removed |
85 | 107 | */
|
86 | 108 | function stripS4HCApiHost(host: string): string {
|
87 | 109 | const [first, ...rest] = host.split('.');
|
@@ -127,3 +149,140 @@ export async function exposePort(port: number, logger?: Logger): Promise<string>
|
127 | 149 | return '';
|
128 | 150 | }
|
129 | 151 | }
|
| 152 | + |
| 153 | +/** |
| 154 | + * Transform a destination object into a TokenExchangeDestination destination, appended with UAA properties. |
| 155 | + * |
| 156 | + * @param destination destination info |
| 157 | + * @param credentials object representing the Client ID and Client Secret and token endpoint |
| 158 | + * @returns Populated OAuth destination |
| 159 | + */ |
| 160 | +function transformToBASSDKDestination( |
| 161 | + destination: Destination, |
| 162 | + credentials: ServiceInfo['uaa'] |
| 163 | +): destinationAPI.DestinationInfo { |
| 164 | + const BASProperties = { |
| 165 | + usage: 'odata_abap,dev_abap,abap_cloud', |
| 166 | + html5DynamicDestination: 'true', |
| 167 | + html5Timeout: '60000' |
| 168 | + } as destinationAPI.BASProperties; |
| 169 | + |
| 170 | + const oauth2UserTokenExchange: destinationAPI.OAuth2UserTokenExchange = { |
| 171 | + clientId: credentials.clientid, |
| 172 | + clientSecret: credentials.clientsecret, |
| 173 | + tokenServiceURL: new URL('/oauth/token', credentials.url).toString(), |
| 174 | + tokenServiceURLType: OAuthUrlType.DEDICATED |
| 175 | + }; |
| 176 | + |
| 177 | + return { |
| 178 | + name: destination.Name, |
| 179 | + description: destination.Description, |
| 180 | + url: new URL(credentials.url), |
| 181 | + type: DestinationType.HTTP, |
| 182 | + proxyType: DestinationProxyType.INTERNET, |
| 183 | + basProperties: BASProperties, |
| 184 | + credentials: { |
| 185 | + authentication: Authentication.OAUTH2_USER_TOKEN_EXCHANGE as destinationAPI.AuthenticationType, |
| 186 | + oauth2UserTokenExchange |
| 187 | + } |
| 188 | + } as destinationAPI.DestinationInfo; |
| 189 | +} |
| 190 | + |
| 191 | +/** |
| 192 | + * Generate a destination name representing the CF target the user is logged into i.e. abap-cloud-mydestination-myorg-mydevspace. |
| 193 | + * |
| 194 | + * @param name destination name |
| 195 | + * @returns formatted destination name using target space and target organisation |
| 196 | + */ |
| 197 | +export async function generateABAPCloudDestinationName(name: string): Promise<string> { |
| 198 | + const target = await cfGetTarget(true); |
| 199 | + if (!target.space) { |
| 200 | + throw new Error(`No Dev Space has been created for the subaccount.`); |
| 201 | + } |
| 202 | + const formattedInstanceName = `${name}-${target.org}-${target.space}`.replace(/\W/gi, '-').toLowerCase(); |
| 203 | + return `abap-cloud-${formattedInstanceName}`.substring(0, 199); |
| 204 | +} |
| 205 | + |
| 206 | +/** |
| 207 | + * Generate a new object representing an OAuth2 token exchange BTP destination. |
| 208 | + * |
| 209 | + * @param destination destination info |
| 210 | + * @param serviceInstanceName name of the service instance, for example, the ABAP Environment service name which is linked to the service technical name i.e. abap-canary |
| 211 | + * @param logger Logger |
| 212 | + * @returns Preconfigured OAuth destination |
| 213 | + */ |
| 214 | +async function generateOAuth2UserTokenExchangeDestination( |
| 215 | + destination: Destination, |
| 216 | + serviceInstanceName: string, |
| 217 | + logger?: Logger |
| 218 | +): Promise<destinationAPI.DestinationInfo> { |
| 219 | + if (!serviceInstanceName) { |
| 220 | + throw new Error(`No service instance name defined.`); |
| 221 | + } |
| 222 | + |
| 223 | + const destinationName: string = await generateABAPCloudDestinationName(destination.Name); |
| 224 | + const instances: CloudFoundryServiceInfo[] = await apiGetServicesInstancesFilteredByType(['destination']); |
| 225 | + const destinationInstance = instances.find( |
| 226 | + (instance: CloudFoundryServiceInfo) => instance.label === DESTINATION_INSTANCE_NAME |
| 227 | + ); |
| 228 | + |
| 229 | + if (!destinationInstance) { |
| 230 | + // Create a new abap-cloud destination instance on the target CF subaccount |
| 231 | + await apiCreateServiceInstance('destination', 'lite', DESTINATION_INSTANCE_NAME, null); |
| 232 | + logger?.info(`New ABAP destination instance ${DESTINATION_INSTANCE_NAME} created.`); |
| 233 | + } |
| 234 | + |
| 235 | + const instanceDetails = await apiGetInstanceCredentials(serviceInstanceName); |
| 236 | + if (!instanceDetails?.credentials) { |
| 237 | + throw new Error(`Could not retrieve SAP BTP credentials for ${serviceInstanceName}.`); |
| 238 | + } |
| 239 | + return transformToBASSDKDestination( |
| 240 | + { |
| 241 | + ...destination, |
| 242 | + Description: `Destination generated by App Studio for '${destination.Name}', Do not remove.`, |
| 243 | + Name: destinationName |
| 244 | + }, |
| 245 | + instanceDetails.credentials as ServiceInfo['uaa'] |
| 246 | + ); |
| 247 | +} |
| 248 | + |
| 249 | +/** |
| 250 | + * Create a new SAP BTP subaccount destination of type 'OAuth2UserTokenExchange' using cf-tools to populate the UAA properties. |
| 251 | + * If the destination already exists, only new or missing properties will be appended, existing fields are not updated with newer values. |
| 252 | + * For example: If an existing SAP BTP destination already contains `WebIDEEnabled` and the value is set as `false`, the value will remain `false` even after the update. |
| 253 | + * |
| 254 | + * Exceptions: an exception will be thrown if the user is not logged into Cloud Foundry, ensure you are logged `cf login -a https://my-test-env.hana.ondemand.com -o staging -s qa` |
| 255 | + * |
| 256 | + * @param destination destination info |
| 257 | + * @param serviceInstanceName name of the service instance, for example, the ABAP Environment service name reflecting name of the service created using a supported service technical name i.e. abap | abap-canary |
| 258 | + * @param logger Logger |
| 259 | + * @returns the newly generated SAP BTP destination |
| 260 | + */ |
| 261 | +export async function createOAuth2UserTokenExchangeDest( |
| 262 | + destination: Destination, |
| 263 | + serviceInstanceName: string, |
| 264 | + logger?: Logger |
| 265 | +): Promise<Destination> { |
| 266 | + if (!isAppStudio()) { |
| 267 | + throw new Error(`Creating a SAP BTP destinations is only supported on SAP Business Application Studio.`); |
| 268 | + } |
| 269 | + try { |
| 270 | + const basSDKDestination: destinationAPI.DestinationInfo = await generateOAuth2UserTokenExchangeDestination( |
| 271 | + destination, |
| 272 | + serviceInstanceName, |
| 273 | + logger |
| 274 | + ); |
| 275 | + // Destination is created on SAP BTP but nothing is returned to validate this! |
| 276 | + await destinationAPI.createDestination(basSDKDestination); |
| 277 | + logger?.debug(`SAP BTP destination ${JSON.stringify(basSDKDestination, null, 2)} created.`); |
| 278 | + // Return updated destination from SAP BTP |
| 279 | + const destinations = await listDestinations(); |
| 280 | + const newDestination = destinations?.[basSDKDestination.name]; |
| 281 | + if (!newDestination) { |
| 282 | + throw new Error('Destination not found on SAP BTP.'); |
| 283 | + } |
| 284 | + return newDestination; |
| 285 | + } catch (error) { |
| 286 | + throw new Error(`An error occurred while generating destination ${destination.Name}: ${error}`); |
| 287 | + } |
| 288 | +} |
0 commit comments