Skip to content

Commit

Permalink
Add Azure DevOps Server support
Browse files Browse the repository at this point in the history
  • Loading branch information
vinokurig committed Jan 21, 2025
1 parent e3c3c88 commit 259113e
Show file tree
Hide file tree
Showing 17 changed files with 916 additions and 46 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2012-2024 Red Hat, Inc.
* Copyright (c) 2012-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
Expand Down Expand Up @@ -172,8 +172,19 @@ public Optional<Boolean> isValid(PersonalAccessToken personalAccessToken) {
public Optional<Pair<Boolean, String>> isValid(PersonalAccessTokenParams params)
throws ScmCommunicationException {
if (!isValidScmServerUrl(params.getScmProviderUrl())) {
LOG.debug("not a valid url {} for current fetcher ", params.getScmProviderUrl());
return Optional.empty();
if (OAUTH_PROVIDER_NAME.equals(params.getScmProviderName())) {
AzureDevOpsServerApiClient azureDevOpsServerApiClient =
new AzureDevOpsServerApiClient(params.getScmProviderUrl(), params.getOrganization());
try {
AzureDevOpsServerUserProfile user = azureDevOpsServerApiClient.getUser(params.getToken());
return Optional.of(Pair.of(Boolean.TRUE, user.getIdentity().getAccountName()));
} catch (ScmItemNotFoundException | ScmBadRequestException e) {
return Optional.empty();
}
} else {
LOG.debug("not a valid url {} for current fetcher ", params.getScmProviderUrl());
return Optional.empty();
}
}

try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.azure.devops;

import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
import static java.net.HttpURLConnection.HTTP_NO_CONTENT;
import static java.net.HttpURLConnection.HTTP_OK;
import static java.time.Duration.ofSeconds;
import static org.eclipse.che.api.factory.server.azure.devops.AzureDevOps.formatAuthorizationHeader;
import static org.eclipse.che.commons.lang.StringUtils.trimEnd;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets;
import com.google.common.io.CharStreams;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.concurrent.Executors;
import java.util.function.Function;
import org.eclipse.che.api.factory.server.scm.exception.ScmBadRequestException;
import org.eclipse.che.api.factory.server.scm.exception.ScmCommunicationException;
import org.eclipse.che.api.factory.server.scm.exception.ScmItemNotFoundException;
import org.eclipse.che.commons.lang.concurrent.LoggingUncaughtExceptionHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Azure DevOps Service API operations helper. */
public class AzureDevOpsServerApiClient {

private static final Logger LOG = LoggerFactory.getLogger(AzureDevOpsServerApiClient.class);

private final HttpClient httpClient;
private final String azureDevOpsServerApiEndpoint;
private final String azureDevOpsServerCollection;
private static final Duration DEFAULT_HTTP_TIMEOUT = ofSeconds(10);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

public AzureDevOpsServerApiClient(
String azureDevOpsServerApiEndpoint, String azureDevOpsServerCollection) {
this.azureDevOpsServerApiEndpoint = trimEnd(azureDevOpsServerApiEndpoint, '/');
this.azureDevOpsServerCollection = azureDevOpsServerCollection;
this.httpClient =
HttpClient.newBuilder()
.executor(
Executors.newCachedThreadPool(
new ThreadFactoryBuilder()
.setUncaughtExceptionHandler(LoggingUncaughtExceptionHandler.getInstance())
.setNameFormat(AzureDevOpsServerApiClient.class.getName() + "-%d")
.setDaemon(true)
.build()))
.connectTimeout(DEFAULT_HTTP_TIMEOUT)
.version(HttpClient.Version.HTTP_1_1)
.build();
}

/**
* Returns the user associated with the provided PAT. The difference from {@code
* getUserWithOAuthToken} is in authorization header and the fact that PAT is associated with
* organization.
*/
public AzureDevOpsServerUserProfile getUser(String token)
throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException {
final String url =
String.format(
"%s/%s/_api/_common/GetUserProfile",
azureDevOpsServerApiEndpoint, azureDevOpsServerCollection);
return getUser(url, formatAuthorizationHeader(token));
}

/** The authorization request varies depending on the type of token. */
private static String formatAuthorizationHeader(String token) {
return "Basic "
+ Base64.getEncoder().encodeToString((":" + token).getBytes(StandardCharsets.UTF_8));
}

private AzureDevOpsServerUserProfile getUser(String url, String authorizationHeader)
throws ScmItemNotFoundException, ScmCommunicationException, ScmBadRequestException {
final HttpRequest userDataRequest =
HttpRequest.newBuilder(URI.create(url))
.headers("Authorization", authorizationHeader)
.timeout(DEFAULT_HTTP_TIMEOUT)
.build();

LOG.trace("executeRequest={}", userDataRequest);
return executeRequest(
httpClient,
userDataRequest,
response -> {
try {
String result =
CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8));
return OBJECT_MAPPER.readValue(result, AzureDevOpsServerUserProfile.class);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}

private <T> T executeRequest(
HttpClient httpClient,
HttpRequest request,
Function<HttpResponse<InputStream>, T> responseConverter)
throws ScmBadRequestException, ScmItemNotFoundException, ScmCommunicationException {
try {
HttpResponse<InputStream> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
LOG.trace("executeRequest={} response {}", request, response.statusCode());
if (response.statusCode() == HTTP_OK) {
return responseConverter.apply(response);
} else if (response.statusCode() == HTTP_NO_CONTENT) {
return null;
} else {
String body = CharStreams.toString(new InputStreamReader(response.body(), Charsets.UTF_8));
switch (response.statusCode()) {
case HTTP_BAD_REQUEST:
throw new ScmBadRequestException(body);
case HTTP_NOT_FOUND:
throw new ScmItemNotFoundException(body);
default:
throw new ScmCommunicationException(
"Unexpected status code " + response.statusCode() + " " + response,
response.statusCode(),
"azure-devops");
}
}
} catch (IOException | InterruptedException | UncheckedIOException e) {
throw new ScmCommunicationException(e.getMessage(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.azure.devops;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/** Azure DevOps Server user's identity. */
@JsonIgnoreProperties(ignoreUnknown = true)
public class AzureDevOpsServerUserIdentity {
private String accountName;
private String mailAddress;

public String getAccountName() {
return accountName;
}

@JsonProperty("AccountName")
public void setAccountName(String accountName) {
this.accountName = accountName;
}

public String getMailAddress() {
return mailAddress;
}

@JsonProperty("MailAddress")
public void setMailAddress(String mailAddress) {
this.mailAddress = mailAddress;
}

@Override
public String toString() {
return "AzureDevOpsServerUserIdentity{"
+ "accountName='"
+ accountName
+ '\''
+ ", mailAddress='"
+ mailAddress
+ '\''
+ '}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.azure.devops;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/** Azure DevOps Server user's preferences. */
@JsonIgnoreProperties(ignoreUnknown = true)
public class AzureDevOpsServerUserPreferences {
private String preferredEmail;

public String getPreferredEmail() {
return preferredEmail;
}

@JsonProperty("PreferredEmail")
public void setPreferredEmail(String preferredEmail) {
this.preferredEmail = preferredEmail;
}

@Override
public String toString() {
return "AzureDevOpsServerUserPreferences{" + "preferredEmail='" + preferredEmail + '\'' + '}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2012-2025 Red Hat, Inc.
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Red Hat, Inc. - initial API and implementation
*/
package org.eclipse.che.api.factory.server.azure.devops;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

/** Azure DevOps Server user's profile. */
@JsonIgnoreProperties(ignoreUnknown = true)
public class AzureDevOpsServerUserProfile {
private AzureDevOpsServerUserIdentity identity;
private AzureDevOpsServerUserPreferences userPreferences;
private String defaultMailAddress;

public AzureDevOpsServerUserIdentity getIdentity() {
return identity;
}

public void setIdentity(AzureDevOpsServerUserIdentity identity) {
this.identity = identity;
}

public String getDefaultMailAddress() {
return defaultMailAddress;
}

public void setDefaultMailAddress(String defaultMailAddress) {
this.defaultMailAddress = defaultMailAddress;
}

public AzureDevOpsServerUserPreferences getUserPreferences() {
return userPreferences;
}

public void setUserPreferences(AzureDevOpsServerUserPreferences userPreferences) {
this.userPreferences = userPreferences;
}

@Override
public String toString() {
return "AzureDevOpsServerUserProfile{"
+ "identity="
+ identity
+ ", userPreferences="
+ userPreferences
+ ", defaultMailAddress='"
+ defaultMailAddress
+ '\''
+ '}';
}
}
Loading

0 comments on commit 259113e

Please sign in to comment.