diff --git a/.gitignore b/.gitignore index 485dee6..499498b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .idea +tool/test.sqlite3 +metadata.sqlite3.new +tool/ingest diff --git a/api-js/src/SDKMeta.ts b/api-js/src/SDKMeta.ts index a97d2e7..7e0ac5a 100644 --- a/api-js/src/SDKMeta.ts +++ b/api-js/src/SDKMeta.ts @@ -4,6 +4,7 @@ import sdkNames from './data/names.json' import sdkTypes from './data/types.json' import sdkPopularity from './data/popularity.json' import sdkReleases from './data/releases.json' +import sdkUserAgents from './data/user_agents.json' export enum Type { // ClientSide is an SDK that runs in a client scenario. @@ -68,3 +69,40 @@ export namespace ReleaseHelpers { export const Earliest = (releases: Release[]) => releases[releases.length - 1]; export const Latest = (releases: Release[]) => releases[0]; } + +export interface UserAgent { + userAgents?: string[]; + wrapperNames?: string[]; +} + +export const UserAgents: Record = sdkUserAgents; + +export namespace UserAgentHelpers { + /** + * Attempts to find an SDK name by checking wrapper names and user agents. + * First checks wrapper names, then user agents, in alphabetical order by SDK ID. + * + * @param identifier - The wrapper name or user agent string to search for + * @returns The SDK name if found, undefined if not found + */ + export const getSDKNameByWrapperOrUserAgent = (identifier: string): string | undefined => { + // Sort the entries by SDK ID to ensure consistent ordering. + const sortedEntries = Object.entries(UserAgents).sort(([a], [b]) => a.localeCompare(b)); + + // First check wrapper names + for (const [sdkId, info] of sortedEntries) { + if (info.wrapperNames?.includes(identifier)) { + return Names[sdkId]; + } + } + + // Then check user agents + for (const [sdkId, info] of sortedEntries) { + if (info.userAgents?.includes(identifier)) { + return Names[sdkId]; + } + } + + return undefined; + } +} diff --git a/api-js/src/data/user_agents.json b/api-js/src/data/user_agents.json new file mode 100644 index 0000000..50df60a --- /dev/null +++ b/api-js/src/data/user_agents.json @@ -0,0 +1,40 @@ +{ + "akamai-base": { + "userAgents": [ + "AkamaiEdgeSDK" + ] + }, + "akamai-edgekv": { + "userAgents": [ + "AkamaiEdgeSDK" + ] + }, + "cloudflare": { + "userAgents": [ + "CloudflareEdgeSDK" + ] + }, + "fastly": { + "userAgents": [ + "FastlyEdgeSDK" + ] + }, + "node-server": { + "userAgents": [ + "NodeJSClient" + ] + }, + "react-native": { + "userAgents": [ + "ReactNativeClient" + ], + "wrapperNames": [ + "react-native-client" + ] + }, + "vercel": { + "userAgents": [ + "VercelEdgeSDK" + ] + } +} diff --git a/api-js/tests/e2e.test.ts b/api-js/tests/e2e.test.ts index db7f93a..78da2e6 100644 --- a/api-js/tests/e2e.test.ts +++ b/api-js/tests/e2e.test.ts @@ -1,4 +1,5 @@ import { Names, Repos, Types, Type, Popularity, Languages, Releases, ReleaseHelpers } from '../src/SDKMeta'; +import { UserAgents, UserAgentHelpers } from '../src/SDKMeta'; test('names', () => { expect(Names['node-server']).toBe('Node.js Server SDK'); @@ -67,3 +68,43 @@ test('eol calculations', () => { expect(ReleaseHelpers.IsApproachingEOL(earliest, new Date(earliestEOL.getTime() - thirty_minutes), hour)).toBe(true); expect(ReleaseHelpers.IsApproachingEOL(earliest, new Date(earliestEOL.getTime() - minute), hour)).toBe(true); }) + +test('user agents', () => { + // Test basic user agent data structure + expect(UserAgents['node-server']).toBeDefined(); + expect(UserAgents['node-server'].userAgents).toContain('NodeJSClient'); + + // Test SDK with both user agents and wrapper names + expect(UserAgents['react-native']).toBeDefined(); + expect(UserAgents['react-native'].userAgents).toContain('ReactNativeClient'); + expect(UserAgents['react-native'].wrapperNames).toContain('react-native-client'); +}); + +describe('UserAgentHelpers.getSDKNameByWrapperOrUserAgent', () => { + test('finds SDK by wrapper name', () => { + const name = UserAgentHelpers.getSDKNameByWrapperOrUserAgent('react-native-client'); + expect(name).toBe('React Native SDK'); + }); + + test('finds SDK by user agent', () => { + const name = UserAgentHelpers.getSDKNameByWrapperOrUserAgent('NodeJSClient'); + expect(name).toBe('Node.js Server SDK'); + }); + + test('returns undefined for unknown identifier', () => { + const name = UserAgentHelpers.getSDKNameByWrapperOrUserAgent('UnknownIdentifier'); + expect(name).toBeUndefined(); + }); + + test('prioritizes wrapper names over user agents', () => { + // In case there's ever a wrapper name that matches a user agent from another SDK, + // we should ensure wrapper names are checked first + const name = UserAgentHelpers.getSDKNameByWrapperOrUserAgent('react-native-client'); + expect(name).toBe('React Native SDK'); + }); + + test('finds edge SDK by user agent', () => { + const name = UserAgentHelpers.getSDKNameByWrapperOrUserAgent('CloudflareEdgeSDK'); + expect(name).toBe('Cloudflare SDK'); + }); +}); diff --git a/api/sdkmeta/data/user_agents.json b/api/sdkmeta/data/user_agents.json new file mode 100644 index 0000000..50df60a --- /dev/null +++ b/api/sdkmeta/data/user_agents.json @@ -0,0 +1,40 @@ +{ + "akamai-base": { + "userAgents": [ + "AkamaiEdgeSDK" + ] + }, + "akamai-edgekv": { + "userAgents": [ + "AkamaiEdgeSDK" + ] + }, + "cloudflare": { + "userAgents": [ + "CloudflareEdgeSDK" + ] + }, + "fastly": { + "userAgents": [ + "FastlyEdgeSDK" + ] + }, + "node-server": { + "userAgents": [ + "NodeJSClient" + ] + }, + "react-native": { + "userAgents": [ + "ReactNativeClient" + ], + "wrapperNames": [ + "react-native-client" + ] + }, + "vercel": { + "userAgents": [ + "VercelEdgeSDK" + ] + } +} diff --git a/api/sdkmeta/sdkmeta.go b/api/sdkmeta/sdkmeta.go index 0f9f145..039429b 100644 --- a/api/sdkmeta/sdkmeta.go +++ b/api/sdkmeta/sdkmeta.go @@ -4,6 +4,7 @@ import ( _ "embed" "encoding/json" "fmt" + "sort" "time" ) @@ -106,6 +107,52 @@ func (r ReleaseList) Latest() Release { return r[0] } +//go:embed data/user_agents.json +var userAgentsJSON []byte + +// SDKUserAgentMap contains user agent and wrapper information for an SDK +type SDKUserAgentMap struct { + UserAgents []string `json:"userAgents,omitempty"` + WrapperNames []string `json:"wrapperNames,omitempty"` +} + +// UserAgents is a map of SDK IDs to their user agent and wrapper information +var UserAgents map[string]SDKUserAgentMap + +// GetSDKNameByWrapperOrUserAgent attempts to find an SDK name by first checking wrapper names, +// then user agents, in alphabetical order by SDK ID. Returns the SDK name and true if found, +// empty string and false if not found. +func GetSDKNameByWrapperOrUserAgent(identifier string) (string, bool) { + // Get sorted SDK IDs to ensure consistent ordering + var sdkIDs []string + for sdkID := range UserAgents { + sdkIDs = append(sdkIDs, sdkID) + } + sort.Strings(sdkIDs) + + // First check wrapper names + for _, sdkID := range sdkIDs { + info := UserAgents[sdkID] + for _, wrapper := range info.WrapperNames { + if wrapper == identifier { + return Names[sdkID], true + } + } + } + + // Then check user agents + for _, sdkID := range sdkIDs { + info := UserAgents[sdkID] + for _, agent := range info.UserAgents { + if agent == identifier { + return Names[sdkID], true + } + } + } + + return "", false +} + func panicOnError(err error) { if err != nil { panic("couldn't initialize SDK Metadata module: " + err.Error()) @@ -119,4 +166,5 @@ func init() { panicOnError(json.Unmarshal(typesJSON, &Types)) panicOnError(json.Unmarshal(releasesJSON, &Releases)) panicOnError(json.Unmarshal(popularityJSON, &Popularity)) + panicOnError(json.Unmarshal(userAgentsJSON, &UserAgents)) } diff --git a/api/sdkmeta/sdkmeta_test.go b/api/sdkmeta/sdkmeta_test.go index 20e6e99..0c25e37 100644 --- a/api/sdkmeta/sdkmeta_test.go +++ b/api/sdkmeta/sdkmeta_test.go @@ -73,3 +73,32 @@ func TestEOLCalculations(t *testing.T) { assert.True(t, earliest.IsApproachingEOL(earliestEOL.Add(-1*time.Minute), time.Hour)) }) } + +func TestUserAgentsAndWrappers(t *testing.T) { + t.Run("user agents map contains expected data", func(t *testing.T) { + nodeInfo := UserAgents["node-server"] + assert.Contains(t, nodeInfo.UserAgents, "NodeJSClient") + + reactNativeInfo := UserAgents["react-native"] + assert.Contains(t, reactNativeInfo.UserAgents, "ReactNativeClient") + assert.Contains(t, reactNativeInfo.WrapperNames, "react-native-client") + }) + + t.Run("GetSDKNameByWrapperOrUserAgent finds by wrapper", func(t *testing.T) { + name, found := GetSDKNameByWrapperOrUserAgent("react-native-client") + assert.True(t, found) + assert.Equal(t, "React Native SDK", name) + }) + + t.Run("GetSDKNameByWrapperOrUserAgent finds by user agent", func(t *testing.T) { + name, found := GetSDKNameByWrapperOrUserAgent("NodeJSClient") + assert.True(t, found) + assert.Equal(t, "Node.js Server SDK", name) + }) + + t.Run("GetSDKNameByWrapperOrUserAgent returns false for unknown identifier", func(t *testing.T) { + name, found := GetSDKNameByWrapperOrUserAgent("UnknownIdentifier") + assert.False(t, found) + assert.Empty(t, name) + }) +} diff --git a/products/user_agents.json b/products/user_agents.json new file mode 100644 index 0000000..50df60a --- /dev/null +++ b/products/user_agents.json @@ -0,0 +1,40 @@ +{ + "akamai-base": { + "userAgents": [ + "AkamaiEdgeSDK" + ] + }, + "akamai-edgekv": { + "userAgents": [ + "AkamaiEdgeSDK" + ] + }, + "cloudflare": { + "userAgents": [ + "CloudflareEdgeSDK" + ] + }, + "fastly": { + "userAgents": [ + "FastlyEdgeSDK" + ] + }, + "node-server": { + "userAgents": [ + "NodeJSClient" + ] + }, + "react-native": { + "userAgents": [ + "ReactNativeClient" + ], + "wrapperNames": [ + "react-native-client" + ] + }, + "vercel": { + "userAgents": [ + "VercelEdgeSDK" + ] + } +} diff --git a/schemas/sdk_metadata.sql b/schemas/sdk_metadata.sql index 906e520..c5d0b4b 100644 --- a/schemas/sdk_metadata.sql +++ b/schemas/sdk_metadata.sql @@ -65,6 +65,16 @@ CREATE TABLE sdk_popularity ( PRIMARY KEY(id, popularity) ); +CREATE TABLE sdk_user_agents ( + id TEXT NOT NULL, + userAgent TEXT +); + +CREATE TABLE sdk_wrappers ( + id TEXT NOT NULL, + wrapper TEXT +); + INSERT INTO sdk_popularity (id, popularity) VALUES ('react-client-sdk', 1), ('node-server', 2), diff --git a/schemas/user_agents.json b/schemas/user_agents.json new file mode 100644 index 0000000..468ed88 --- /dev/null +++ b/schemas/user_agents.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://launchdarkly.com/sdk-meta/user_agents.json", + "title": "SDK User Agents", + "description": "List of SDK User Agents and Wrappers", + "type": "object", + "$defs": { + "SDKUserAgentMap": { + "type": "object", + "description": "Map of user agents for an SDK", + "properties": { + "userAgents": { + "$ref": "#/$defs/AgentValue" + }, + "wrapperNames": { + "$ref": "#/$defs/AgentValue" + } + }, + "minProperties": 1, + "additionalProperties": false + }, + "AgentValue": { + "type": "array", + "description": "An array of header values, each matching a pattern.", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^[A-Za-z-]*$" + } + } + }, + "patternProperties": { + "^[a-z-]+$": { + "$ref": "#/$defs/SDKUserAgentMap" + } + }, + "additionalProperties": false +} diff --git a/scripts/generate-products.sh b/scripts/generate-products.sh index 5de0595..7f39cc6 100755 --- a/scripts/generate-products.sh +++ b/scripts/generate-products.sh @@ -32,6 +32,10 @@ sqlite3 -json metadata.sqlite3 "SELECT * from sdk_feature_info;" | sqlite3 -json metadata.sqlite3 "SELECT * from sdk_popularity;" | jq -S 'reduce .[] as $item ({}; .[$item.id] = $item.popularity)' > products/popularity.json +# Generate user agents and wrappers data +sqlite3 -json metadata.sqlite3 "SELECT id, userAgent as value, 'userAgents' as type FROM sdk_user_agents UNION ALL SELECT id, wrapper as value, 'wrapperNames' as type FROM sdk_wrappers;" | + jq -S 'reduce .[] as $item ({}; .[$item.id] = (.[$item.id] // {}) + { ($item.type): ((.[$item.id][$item.type] // []) + [$item.value]) })' > products/user_agents.json + ./scripts/eols.sh metadata.sqlite3 | jq -n 'reduce inputs[] as $input ({}; .[$input.id] += [$input | del(.id)])' > products/releases.json diff --git a/tool/cmd/ingest/main.go b/tool/cmd/ingest/main.go index f4e70c7..edd4cae 100644 --- a/tool/cmd/ingest/main.go +++ b/tool/cmd/ingest/main.go @@ -17,12 +17,13 @@ import ( ) type metadataV1 struct { - Name string `json:"name"` - Path string `json:"path"` - UserAgent string `json:"user-agent"` - Type string `json:"type"` - Languages []string `json:"languages"` - Features map[string]struct { + Name string `json:"name"` + Path string `json:"path"` + UserAgents []string `json:"userAgents"` + WrapperNames []string `json:"wrapperNames"` + Type string `json:"type"` + Languages []string `json:"languages"` + Features map[string]struct { Introduced string `json:"introduced"` Deprecated *string `json:"deprecated"` Removed *string `json:"removed"` @@ -142,7 +143,9 @@ func run(args *args) error { } return nil }, - "features": insertFeatures, + "features": insertFeatures, + "userAgents": insertUserAgents, + "wrapperNames": insertWrapperNames, } if !args.offline { @@ -278,3 +281,41 @@ func insertReleases(tx *sql.Tx, id string, release []releases.Parsed) error { } return nil } + +func insertUserAgents(tx *sql.Tx, id string, metadata *metadataV1) error { + if len(metadata.UserAgents) == 0 { + return nil + } + stmt, err := tx.Prepare("INSERT INTO sdk_user_agents (id, userAgent) VALUES (?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, userAgent := range metadata.UserAgents { + if _, err := stmt.Exec(id, userAgent); err != nil { + return err + } + } + + return nil +} + +func insertWrapperNames(tx *sql.Tx, id string, metadata *metadataV1) error { + if len(metadata.WrapperNames) == 0 { + return nil + } + stmt, err := tx.Prepare("INSERT INTO sdk_wrappers (id, wrapper) VALUES (?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, wrapper := range metadata.WrapperNames { + if _, err := stmt.Exec(id, wrapper); err != nil { + return err + } + } + + return nil +}