Skip to content

Commit df2d965

Browse files
longieirlIainSAPdevinea
authored
2702/expose new btp destination creation (#2799)
* fix(axios-ext): expose new create BTP destination function * fix(btp-utils): add unit tests * fix(btp-utils): merge with master yarn lock * fix(btp-utils): add changeset * fix(btp-utils): cleanup * fix(btp-utils): address sonar issues * fix(btp-utils): return oauth destination * fix(btp-utils): change to minor version bump * fix(btp-utils): updates to reflect changes to bas-sdk * fix(btp-utils: consume bas-sdk to generate and create new oauth2 destination * fix(btp-utils): fix linting * fix(btp-utils): update tests based on changed in bas-sdk * fix(btp-utils): bump to latest version * fix(btp-utils): return BTP destination, update tests and docs * fix(btp-utils): cleanup text --------- Co-authored-by: IainSAP <[email protected]> Co-authored-by: Austin Devine <[email protected]>
1 parent ac5c2ed commit df2d965

File tree

7 files changed

+394
-9
lines changed

7 files changed

+394
-9
lines changed

.changeset/thirty-trains-beam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sap-ux/btp-utils': minor
3+
---
4+
5+
new functionality to generate OAuth2TokenExchange BTP destination using cf-tools

packages/btp-utils/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
},
2828
"dependencies": {
2929
"@sap/cf-tools": "3.2.2",
30-
"axios": "1.7.4"
30+
"axios": "1.7.4",
31+
"@sap/bas-sdk": "3.11.2"
3132
},
3233
"devDependencies": {
3334
"nock": "13.4.0",

packages/btp-utils/src/app-studio.ts

Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,38 @@
11
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';
39
import type { Logger } from '@sap-ux/logger';
410
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';
628

729
/**
830
* HTTP header that is to be used for encoded credentials when communicating with a destination service instance.
931
*/
1032
export const BAS_DEST_INSTANCE_CRED_HEADER = 'bas-destination-instance-cred';
1133

1234
/**
13-
* Check if this is exectued in SAP Business Application Studio.
35+
* Check if this is executed in SAP Business Application Studio.
1436
*
1537
* @returns true if yes
1638
*/
@@ -81,7 +103,7 @@ export type Destinations = { [name: string]: Destination };
81103
* Helper function to strip `-api` from the host name.
82104
*
83105
* @param host -
84-
* @returns
106+
* @returns an updated string value, with `-api` removed
85107
*/
86108
function stripS4HCApiHost(host: string): string {
87109
const [first, ...rest] = host.split('.');
@@ -127,3 +149,140 @@ export async function exposePort(port: number, logger?: Logger): Promise<string>
127149
return '';
128150
}
129151
}
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+
}

packages/btp-utils/src/destination.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
/**
2+
* Support different Token Service URL Types
3+
*/
4+
export enum DestinationType {
5+
HTTP = 'HTTP',
6+
LDAP = 'LDAP',
7+
MAIL = 'MAIL',
8+
RFC = 'RFC'
9+
}
10+
11+
/**
12+
* Support different Token Service URL Types
13+
*/
14+
export enum OAuthUrlType {
15+
DEDICATED = 'Dedicated',
16+
COMMON = 'Common'
17+
}
18+
119
/**
220
* Support destination authentication types
321
*/
@@ -76,6 +94,7 @@ export interface Destination extends Partial<AdditionalDestinationProperties> {
7694
Authentication: string;
7795
ProxyType: string;
7896
Description: string;
97+
7998
/**
8099
* N.B. Not the host but the full destination URL property!
81100
*/
@@ -286,3 +305,25 @@ export const AbapEnvType = {
286305
} as const;
287306

288307
export type AbapEnvType = (typeof AbapEnvType)[keyof typeof AbapEnvType];
308+
309+
/**
310+
* OAuth destination properties.
311+
*/
312+
export interface OAuth2Destination extends Omit<Destination, 'Host'>, Partial<AdditionalDestinationProperties> {
313+
URL: string; // Required for creation flow
314+
clientSecret: string;
315+
clientId: string;
316+
tokenServiceURL: string;
317+
tokenServiceURLType?: 'Dedicated'; // Optional for OAuth2Password destinations
318+
}
319+
320+
export interface CloudFoundryServiceInfo {
321+
label: string;
322+
serviceName: string;
323+
guid?: string;
324+
tags?: string[];
325+
alwaysShow?: boolean;
326+
plan_guid?: string;
327+
plan?: string;
328+
credentials?: any;
329+
}

0 commit comments

Comments
 (0)