Skip to content

Commit

Permalink
feat: Add x-spice-user-agent header (#25)
Browse files Browse the repository at this point in the history
* feat: Add user agent generation and test

* fix: MacOS and Windows test runs for useragent

* fix: User agent OS name and version on Windows

* feat: Add x-spice-user-agent header
  • Loading branch information
peasee authored Oct 5, 2024
1 parent 6d4cfe2 commit 5f280cd
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 7 deletions.
87 changes: 85 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,93 @@ on:
workflow_dispatch:

jobs:
build:
build_multi_os:
name: Build and test ${{matrix.os}}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4

runs-on: ubuntu-latest
- name: Set up JDK 17 (Oracle)
uses: actions/setup-java@v4
with:
java-version: 17
distribution: oracle

- name: Display Java version
run: java -version

- name: Display Javac version
run: javac -version

- name: Display Maven version
run: mvn -version

- name: Build (Unix)
if: matrix.os != 'windows-latest'
run: mvn install -DskipTests=true -Dgpg.skip -B -V

- name: Build (Windows)
if: matrix.os == 'windows-latest'
run: mvn --% install -DskipTests=true -Dgpg.skip -B -V # tell powershell to stop parsing with --% so it doesn't error with "Unknown lifecycle phase .skip"

- name: Install Spice (https://install.spiceai.org) (Linux)
if: matrix.os == 'ubuntu-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl https://install.spiceai.org | /bin/bash
echo "$HOME/.spice/bin" >> $GITHUB_PATH
$HOME/.spice/bin/spice install
- name: Install Spice (https://install.spiceai.org) (MacOS)
if: matrix.os == 'macos-latest'
run: |
brew install spiceai/spiceai/spice
brew install spiceai/spiceai/spiced
- name: install Spice (Windows)
if: matrix.os == 'windows-latest'
run: |
curl -L "https://install.spiceai.org/Install.ps1" -o Install.ps1 && PowerShell -ExecutionPolicy Bypass -File ./Install.ps1
- name: add Spice bin to PATH (Windows)
if: matrix.os == 'windows-latest'
run: |
Add-Content $env:GITHUB_PATH (Join-Path $HOME ".spice\bin")
shell: pwsh

- name: Init and start spice app (Unix)
if: matrix.os != 'windows-latest'
run: |
spice init spice_qs
cd spice_qs
spice add spiceai/quickstart
spiced &> spice.log &
# time to initialize added dataset
sleep 10
- name: Init and start spice app (Windows)
if: matrix.os == 'windows-latest'
run: |
spice init spice_qs
cd spice_qs
spice add spiceai/quickstart
Start-Process -FilePath spice run
# time to initialize added dataset
Start-Sleep -Seconds 10
shell: pwsh

- name: Test
run: mvn test -B
env:
API_KEY: ${{ secrets.SPICE_CLOUD_API_KEY }}

build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
Expand Down
39 changes: 37 additions & 2 deletions src/main/java/ai/spice/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ public class Config {

LOCAL_FLIGHT_ADDRESS = System.getenv("SPICE_FLIGHT_URL") != null ? System.getenv("SPICE_FLIGHT_URL")
: "http://localhost:50051";

CLOUD_HTTP_ADDRESS = System.getenv("SPICE_HTTP_URL") != null ? System.getenv("SPICE_HTTP_URL")
: "https://data.spiceai.io";

LOCAL_HTTP_ADDRESS = System.getenv("SPICE_HTTP_URL") != null ? System.getenv("SPICE_HTTP_URL")
: "http://localhost:8090";
}
Expand Down Expand Up @@ -92,4 +92,39 @@ public static URI getLocalHttpAddressUri() throws URISyntaxException {
public static URI getCloudHttpAddressUri() throws URISyntaxException {
return new URI(CLOUD_HTTP_ADDRESS);
}

/**
* Returns the Spice SDK user agent for this system, including the package
* version, system OS, version and arch.
*
* @return the Spice SDK user agent string for this system.
*/
public static String getUserAgent() {
// change the os arch to match the pattern set in other SDKs
String osArch = System.getProperty("os.arch");
if (osArch.equals("amd64")) {
osArch = "x86_64";
} else if (osArch.equals("x86")) {
osArch = "i386";
}

// change the os name to match the pattern set in other SDKs
String osName = System.getProperty("os.name");
if (osName.equals("Mac OS X")) {
osName = "Darwin";
} else if (osName.contains("Windows")) {
osName = "Windows"; // this renames Windows OS names like "Windows Server 2022" to just "Windows"
}

// The Windows OS version strings also include the arch after the version, so we
// need to remove it
String osVersion = System.getProperty("os.version");
if (osName.equals("Windows")) {
osVersion = osVersion.split(" ")[0];
}

return "spice-java " + Version.SPICE_JAVA_VERSION + " (" + osName + "/"
+ System.getProperty("os.version") + " "
+ osArch + ")";
}
}
42 changes: 42 additions & 0 deletions src/main/java/ai/spice/HeaderAuthMiddlewareFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package ai.spice;

import java.util.Map;

import org.apache.arrow.flight.CallHeaders;
import org.apache.arrow.flight.CallInfo;
import org.apache.arrow.flight.CallStatus;
import org.apache.arrow.flight.FlightClientMiddleware;
import org.apache.arrow.flight.FlightClientMiddleware.Factory;
import org.apache.arrow.flight.auth2.ClientIncomingAuthHeaderMiddleware;

public class HeaderAuthMiddlewareFactory implements Factory {
private final ClientIncomingAuthHeaderMiddleware.Factory authFactory;
private final Map<String, String> headers;

public HeaderAuthMiddlewareFactory(ClientIncomingAuthHeaderMiddleware.Factory authFactory,
Map<String, String> headers) {
this.authFactory = authFactory;
this.headers = headers;
}

@Override
public FlightClientMiddleware onCallStarted(CallInfo callInfo) {
return new FlightClientMiddleware() {
@Override
public void onBeforeSendingHeaders(CallHeaders callHeaders) {
authFactory.onCallStarted(callInfo).onBeforeSendingHeaders(callHeaders);
headers.forEach(callHeaders::insert);
}

@Override
public void onHeadersReceived(CallHeaders callHeaders) {
authFactory.onCallStarted(callInfo).onHeadersReceived(callHeaders);
}

@Override
public void onCallCompleted(CallStatus callStatus) {
authFactory.onCallStarted(callInfo).onCallCompleted(callStatus);
}
};
}
}
17 changes: 14 additions & 3 deletions src/main/java/ai/spice/SpiceClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ of this software and associated documentation files (the "Software"), to deal
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;

import org.apache.arrow.flight.CallStatus;
Expand Down Expand Up @@ -112,12 +114,20 @@ public SpiceClient(String appId, String apiKey, URI flightAddress, URI httpAddre
return;
}

final ClientIncomingAuthHeaderMiddleware.Factory factory = new ClientIncomingAuthHeaderMiddleware.Factory(
// prepare additional headers to insert into Flight requests
Map<String, String> headers = new HashMap<>();
headers.put("X-Spice-User-Agent", Config.getUserAgent());

final ClientIncomingAuthHeaderMiddleware.Factory authFactory = new ClientIncomingAuthHeaderMiddleware.Factory(
new ClientBearerHeaderHandler());

final FlightClient client = builder.intercept(factory).build();
// builder can't chain .intercept()s, so we need to chain the middleware
// factories instead
final HeaderAuthMiddlewareFactory combinedFactory = new HeaderAuthMiddlewareFactory(authFactory, headers);

final FlightClient client = builder.intercept(combinedFactory).build();
client.handshake(new CredentialCallOption(new BasicAuthCredentialWriter(this.appId, this.apiKey)));
this.authCallOptions = factory.getCredentialCallOption();
this.authCallOptions = authFactory.getCredentialCallOption();
this.flightClient = new FlightSqlClient(client);
}

Expand Down Expand Up @@ -151,6 +161,7 @@ public void refresh(String dataset) throws ExecutionException {
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI(String.format("%s/v1/datasets/%s/acceleration/refresh", this.httpAddress, dataset)))
.header("Content-Type", "application/json")
.header("X-Spice-User-Agent", Config.getUserAgent())
.POST(HttpRequest.BodyPublishers.noBody())
.build();

Expand Down
35 changes: 35 additions & 0 deletions src/main/java/ai/spice/Version.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
Copyright 2024 The Spice.ai OSS Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

package ai.spice;

public class Version {
/**
* spice-java SDK version, defined statically to support more platforms vs
* relying on .jar packaging
*/
public static final String SPICE_JAVA_VERSION;

static {
SPICE_JAVA_VERSION = "0.3.0";
}
}
44 changes: 44 additions & 0 deletions src/test/java/ai/spice/UserAgentTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Copyright 2024 The Spice.ai OSS Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

package ai.spice;

import java.util.concurrent.ExecutionException;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import junit.framework.TestCase;

public class UserAgentTest
extends TestCase {
public void testUserAgent() throws ExecutionException, InterruptedException {
// use a regex to match the expected user agent
String regex = "spice-java \\d+\\.\\d+\\.\\d+ \\((Linux|Windows|Darwin)/[\\d\\w\\.\\-\\_]+ (x86_64|aarch64|i386)\\)";
Pattern pattern = Pattern.compile(regex);

String userAgent = Config.getUserAgent();
Matcher matcher = pattern.matcher(userAgent);

assertTrue("User agent did not match the expected pattern: " + userAgent, matcher.matches());
}
}

0 comments on commit 5f280cd

Please sign in to comment.