From 3eac5b5b6b5802cf598ba94317a1284d10532ded Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Thu, 4 Jul 2024 16:41:12 +0200 Subject: [PATCH] ci: add scripts to validate that public API has not changed (#152) * ci: add scripts to validate that public API has not changed * ci: install sourcekitten * ci: setup ssh-agent * ci: add more info on rememdy * ci: add documentation + refactor * ci: add support for ConfidenceProvider in API diff --- .github/workflows/ci.yaml | 16 ++ .gitignore | 3 +- CONTRIBUTING.md | 18 +- api/ConfidenceProvider_public_api.json | 43 +++ api/Confidence_public_api.json | 380 +++++++++++++++++++++++++ scripts/api_diff.sh | 49 ++++ scripts/extract_public_funcs.py | 52 ++++ scripts/generate_public_api.sh | 22 ++ 8 files changed, 578 insertions(+), 5 deletions(-) create mode 100644 api/ConfidenceProvider_public_api.json create mode 100644 api/Confidence_public_api.json create mode 100755 scripts/api_diff.sh create mode 100644 scripts/extract_public_funcs.py create mode 100755 scripts/generate_public_api.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ffb1f3dd..20fad9c3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,22 @@ on: - 'main' jobs: + API-diff: + strategy: + matrix: + module: ["ConfidenceProvider", "Confidence"] + runs-on: macOS-latest + steps: + - name: install sourcekitten + run: brew install sourcekitten + - uses: actions/checkout@v3 + - name: webfactory/ssh-agent + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.SDK_REPO_PRIVATE_KEY }} + - name: Run public API diff + run: scripts/api_diff.sh ${{ matrix.module }} + Tests: runs-on: macOS-latest steps: diff --git a/.gitignore b/.gitignore index 7813b9a7..5bce6d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ DerivedData/ .netrc .build .mockingbird -project.json \ No newline at end of file +project.json +scripts/raw_api.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a858a4a..299b11c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -## Contributing +# Contributing Open the project in Xcode and build by Product -> Build. -### Linting code +## Linting code Code is automatically linted during a build in Xcode. If you need to manually lint: @@ -11,7 +11,7 @@ brew install swiftlint swiftlint ``` -### Formatting code +## Formatting code You can automatically format your code using: @@ -19,7 +19,17 @@ You can automatically format your code using: ./scripts/swift-format ``` -### Running tests +## API diffs +We run a script to make sure that we don't make changes to the public API without intending to. +The diff script and the script to generate a new "golden api file" uses a tool called [SourceKitten](https://github.com/jpsim/SourceKitten) which can be installed using homebrew (`brew install sourcekitten`). + +### The expected workflow is: +* Write code (that may change the public API). +* Optionally run `./scripts/api_diff.sh` to detect the api change. +* Run `./scripts/generate_public_api.sh` -- this will update the file in `./api`. +* Commit both code and the updated API file in the same commit. + +## Running tests IT tests require a Confidence client token to reach remote servers. The token can be created on the Confidence portal. The Confidence organization used for IT tests is named `confidence-test` (you may need to request access). diff --git a/api/ConfidenceProvider_public_api.json b/api/ConfidenceProvider_public_api.json new file mode 100644 index 00000000..ec00367a --- /dev/null +++ b/api/ConfidenceProvider_public_api.json @@ -0,0 +1,43 @@ +[ + { + "className": "ConfidenceFeatureProvider", + "apiFunctions": [ + { + "name": "init(confidence:initializationStrategy:)", + "declaration": "public convenience init(confidence: Confidence, initializationStrategy: InitializationStrategy = .fetchAndActivate)" + }, + { + "name": "initialize(initialContext:)", + "declaration": "public func initialize(initialContext: OpenFeature.EvaluationContext?)" + }, + { + "name": "onContextSet(oldContext:newContext:)", + "declaration": "public func onContextSet(\n oldContext: OpenFeature.EvaluationContext?,\n newContext: OpenFeature.EvaluationContext\n)" + }, + { + "name": "getBooleanEvaluation(key:defaultValue:context:)", + "declaration": "public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws\n-> OpenFeature.ProviderEvaluation" + }, + { + "name": "getStringEvaluation(key:defaultValue:context:)", + "declaration": "public func getStringEvaluation(key: String, defaultValue: String, context: EvaluationContext?) throws\n-> OpenFeature.ProviderEvaluation" + }, + { + "name": "getIntegerEvaluation(key:defaultValue:context:)", + "declaration": "public func getIntegerEvaluation(key: String, defaultValue: Int64, context: EvaluationContext?) throws\n-> OpenFeature.ProviderEvaluation" + }, + { + "name": "getDoubleEvaluation(key:defaultValue:context:)", + "declaration": "public func getDoubleEvaluation(key: String, defaultValue: Double, context: EvaluationContext?) throws\n-> OpenFeature.ProviderEvaluation" + }, + { + "name": "getObjectEvaluation(key:defaultValue:context:)", + "declaration": "public func getObjectEvaluation(key: String, defaultValue: OpenFeature.Value, context: EvaluationContext?)\nthrows -> OpenFeature.ProviderEvaluation" + }, + { + "name": "observe()", + "declaration": "public func observe() -> AnyPublisher" + } + ] + } +] \ No newline at end of file diff --git a/api/Confidence_public_api.json b/api/Confidence_public_api.json new file mode 100644 index 00000000..787310e5 --- /dev/null +++ b/api/Confidence_public_api.json @@ -0,0 +1,380 @@ +[ + { + "className": "Confidence", + "apiFunctions": [ + { + "name": "activate()", + "declaration": "public func activate() throws" + }, + { + "name": "fetchAndActivate()", + "declaration": "public func fetchAndActivate() async throws" + }, + { + "name": "asyncFetch()", + "declaration": "public func asyncFetch()" + }, + { + "name": "getEvaluation(key:defaultValue:)", + "declaration": "public func getEvaluation(key: String, defaultValue: T) throws -> Evaluation" + }, + { + "name": "getValue(key:defaultValue:)", + "declaration": "public func getValue(key: String, defaultValue: T) -> T" + }, + { + "name": "contextChanges()", + "declaration": "public func contextChanges() -> AnyPublisher" + }, + { + "name": "track(eventName:data:)", + "declaration": "public func track(eventName: String, data: ConfidenceStruct) throws" + }, + { + "name": "track(producer:)", + "declaration": "public func track(producer: ConfidenceProducer)" + }, + { + "name": "flush()", + "declaration": "public func flush()" + }, + { + "name": "getContext()", + "declaration": "public func getContext() -> ConfidenceStruct" + }, + { + "name": "putContext(key:value:)", + "declaration": "public func putContext(key: String, value: ConfidenceValue)" + }, + { + "name": "putContext(context:)", + "declaration": "public func putContext(context: ConfidenceStruct)" + }, + { + "name": "putContext(context:removeKeys:)", + "declaration": "public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = [])" + }, + { + "name": "removeKey(key:)", + "declaration": "public func removeKey(key: String)" + }, + { + "name": "withContext(_:)", + "declaration": "public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender" + } + ] + }, + { + "className": "Builder", + "apiFunctions": [ + { + "name": "init(clientSecret:loggerLevel:)", + "declaration": "public init(clientSecret: String, loggerLevel: LoggerLevel = .WARN)" + }, + { + "name": "withContext(initialContext:)", + "declaration": "public func withContext(initialContext: ConfidenceStruct) -> Builder" + }, + { + "name": "withRegion(region:)", + "declaration": "public func withRegion(region: ConfidenceRegion) -> Builder" + }, + { + "name": "build()", + "declaration": "public func build() -> Confidence" + } + ] + }, + { + "className": "ConfidenceAppLifecycleProducer", + "apiFunctions": [ + { + "name": "init()", + "declaration": "public init()" + }, + { + "name": "deinit", + "declaration": "deinit" + }, + { + "name": "produceEvents()", + "declaration": "public func produceEvents() -> AnyPublisher" + }, + { + "name": "produceContexts()", + "declaration": "public func produceContexts() -> AnyPublisher" + } + ] + }, + { + "className": "ConfidenceClientOptions", + "apiFunctions": [ + { + "name": "init(credentials:region:initializationStrategy:)", + "declaration": "public init(\n credentials: ConfidenceClientCredentials,\n region: ConfidenceRegion? = nil,\n initializationStrategy: InitializationStrategy = .fetchAndActivate\n)" + } + ] + }, + { + "className": "ConfidenceClientCredentials", + "apiFunctions": [ + { + "name": "getSecret()", + "declaration": "public func getSecret() -> String" + } + ] + }, + { + "className": "ConfidenceMetadata", + "apiFunctions": [ + { + "name": "init(name:version:)", + "declaration": "public init(name: String, version: String)" + } + ] + }, + { + "className": "Event", + "apiFunctions": [ + { + "name": "init(name:data:shouldFlush:)", + "declaration": "public init(name: String, data: ConfidenceStruct = [:], shouldFlush: Bool = false)" + } + ] + }, + { + "className": "ConfidenceScreenTracker", + "apiFunctions": [ + { + "name": "init()", + "declaration": "public init()" + }, + { + "name": "produceEvents()", + "declaration": "public func produceEvents() -> AnyPublisher" + } + ] + }, + { + "className": "ConfidenceValue", + "apiFunctions": [ + { + "name": "init(from:)", + "declaration": "public required init(from decoder: Decoder) throws" + }, + { + "name": "init(boolean:)", + "declaration": "public init(boolean: Bool)" + }, + { + "name": "init(string:)", + "declaration": "public init(string: String)" + }, + { + "name": "init(integer:)", + "declaration": "public init(integer: Int)" + }, + { + "name": "init(double:)", + "declaration": "public init(double: Double)" + }, + { + "name": "init(date:)", + "declaration": "public init(date: DateComponents)" + }, + { + "name": "init(timestamp:)", + "declaration": "public init(timestamp: Date)" + }, + { + "name": "init(booleanList:)", + "declaration": "public init(booleanList: [Bool])" + }, + { + "name": "init(stringList:)", + "declaration": "public init(stringList: [String])" + }, + { + "name": "init(integerList:)", + "declaration": "public init(integerList: [Int])" + }, + { + "name": "init(doubleList:)", + "declaration": "public init(doubleList: [Double])" + }, + { + "name": "init(nullList:)", + "declaration": "public init(nullList: [()])" + }, + { + "name": "init(dateList:)", + "declaration": "public init(dateList: [DateComponents])" + }, + { + "name": "init(timestampList:)", + "declaration": "public init(timestampList: [Date])" + }, + { + "name": "init(structure:)", + "declaration": "public init(structure: [String: ConfidenceValue])" + }, + { + "name": "init(null:)", + "declaration": "public init(null: ())" + }, + { + "name": "asBoolean()", + "declaration": "public func asBoolean() -> Bool?" + }, + { + "name": "asString()", + "declaration": "public func asString() -> String?" + }, + { + "name": "asInteger()", + "declaration": "public func asInteger() -> Int?" + }, + { + "name": "asDouble()", + "declaration": "public func asDouble() -> Double?" + }, + { + "name": "asDateComponents()", + "declaration": "public func asDateComponents() -> DateComponents?" + }, + { + "name": "asDate()", + "declaration": "public func asDate() -> Date?" + }, + { + "name": "asList()", + "declaration": "public func asList() -> [ConfidenceValue]?" + }, + { + "name": "asStructure()", + "declaration": "public func asStructure() -> [String: ConfidenceValue]?" + }, + { + "name": "isNull()", + "declaration": "public func isNull() -> Bool" + }, + { + "name": "type()", + "declaration": "public func type() -> ConfidenceValueType" + }, + { + "name": "==(_:_:)", + "declaration": "public static func == (lhs: ConfidenceValue, rhs: ConfidenceValue) -> Bool" + } + ] + }, + { + "className": "DefaultStorage", + "apiFunctions": [ + { + "name": "init(filePath:)", + "declaration": "public init(filePath: String)" + }, + { + "name": "save(data:)", + "declaration": "public func save(data: Encodable) throws" + }, + { + "name": "load(defaultValue:)", + "declaration": "public func load(defaultValue: T) throws -> T where T: Decodable" + }, + { + "name": "clear()", + "declaration": "public func clear() throws" + }, + { + "name": "isEmpty()", + "declaration": "public func isEmpty() -> Bool" + }, + { + "name": "getConfigUrl()", + "declaration": "public func getConfigUrl() throws -> URL" + } + ] + }, + { + "className": "FlagPath", + "apiFunctions": [ + { + "name": "getPath(for:)", + "declaration": "public static func getPath(for path: String) throws -> FlagPath" + } + ] + }, + { + "className": "HttpClientResponse", + "apiFunctions": [ + { + "name": "init(decodedData:decodedError:response:)", + "declaration": "public init(decodedData: T? = nil, decodedError: HttpError? = nil, response: HTTPURLResponse)" + } + ] + }, + { + "className": "HttpError", + "apiFunctions": [ + { + "name": "init(code:message:details:)", + "declaration": "public init(code: Int, message: String, details: [String])" + } + ] + }, + { + "className": "NetworkClient", + "apiFunctions": [ + { + "name": "init(session:baseUrl:defaultHeaders:retry:)", + "declaration": "public init(\n session: URLSession? = nil,\n baseUrl: String,\n defaultHeaders: [String: String] = [:],\n retry: Retry = .none\n)" + }, + { + "name": "post(path:data:)", + "declaration": "public func post(\n path: String,\n data: Encodable\n) async throws -> HttpClientResult" + } + ] + }, + { + "className": "ExponentialBackoffRetryHandler", + "apiFunctions": [ + { + "name": "retryIn()", + "declaration": "public func retryIn() -> TimeInterval?" + } + ] + }, + { + "className": "NoneRetryHandler", + "apiFunctions": [ + { + "name": "retryIn()", + "declaration": "public func retryIn() -> TimeInterval?" + } + ] + }, + { + "className": "NetworkStruct", + "apiFunctions": [ + { + "name": "init(fields:)", + "declaration": "public init(fields: [String: NetworkValue])" + } + ] + }, + { + "className": "RemoteConfidenceResolveClient", + "apiFunctions": [ + { + "name": "resolve(flags:ctx:)", + "declaration": "public func resolve(flags: [String], ctx: ConfidenceStruct) async throws -> ResolvesResult" + }, + { + "name": "resolve(ctx:)", + "declaration": "public func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult" + } + ] + } +] \ No newline at end of file diff --git a/scripts/api_diff.sh b/scripts/api_diff.sh new file mode 100755 index 00000000..29e35953 --- /dev/null +++ b/scripts/api_diff.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/../" + +# exit if param is not supplied +if [ -z "$1" ]; then + echo "Please provide the module name as a parameter." + exit 1 +fi +MODULE=$1 + +# Generate the json file with: +sourcekitten doc --module-name ${MODULE} -- -scheme Confidence-Package -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' > $script_dir/${MODULE}_raw_api.json + +# Extract the public API from the raw api json file +python3 $script_dir/extract_public_funcs.py $script_dir/${MODULE}_raw_api.json $script_dir/${MODULE}_current_public_api.json + +# Clean up the raw api json file +rm $script_dir/${MODULE}_raw_api.json + +# Compare the public API with the previous public API and exit with 1 if there are changes +echo "Comparing genereated public API with previous public API" +set +e +git diff --no-index --exit-code $root_dir/api/${MODULE}_public_api.json $script_dir/${MODULE}_current_public_api.json +# Capture the exit code of the git diff command +diff_exit_code=$? +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color +printf "\n" + +if [ $diff_exit_code -eq 0 ]; then + printf "${GREEN}No changes detected in the public API.${NC}" +else + printf "${RED}Changes detected in the public API. Please review the differences.\n +If the changes are _intended_, please update the public API by running the generate_public_api_list.sh script and commit the changes to public_api.json.\n +If the changes are unintended, please investigate the changes and update the source code accordingly.${NC}" +fi + +# Clean up the current public api json file +rm $script_dir/${MODULE}_current_public_api.json + +# Exit with the diff's exit code to maintain the intended behavior +exit $diff_exit_code \ No newline at end of file diff --git a/scripts/extract_public_funcs.py b/scripts/extract_public_funcs.py new file mode 100644 index 00000000..acd3dd31 --- /dev/null +++ b/scripts/extract_public_funcs.py @@ -0,0 +1,52 @@ +import json +import sys +from collections import defaultdict + +def extract_public_api(api_json_path, output_json_path): + with open(api_json_path, 'r') as file: + data = json.load(file) + + api_structure = defaultdict(list) + + def traverse(item, current_context=None): + if isinstance(item, dict): + # Update context if the item is a public class, struct, or enum + if 'key.kind' in item and item['key.kind'] in {'source.lang.swift.decl.class', 'source.lang.swift.decl.struct', 'source.lang.swift.decl.enum'}: + if item.get('key.accessibility') == 'source.lang.swift.accessibility.public': + current_context = item.get('key.name', 'Unnamed Context') + else: + current_context = None # Reset context if not public + + # Check if item is a public function declaration + if current_context and 'key.kind' in item and item['key.kind'].startswith('source.lang.swift.decl.function') and item.get('key.accessibility') == 'source.lang.swift.accessibility.public': + function_info = {'name': item.get('key.name', 'Unnamed Function'), 'declaration': item.get('key.parsed_declaration', 'unknown declaration') } + api_structure[current_context].append(function_info) + + # Recursively handle nested structures + for key in item: + traverse(item[key], current_context) + elif isinstance(item, list): + for sub_item in item: + traverse(sub_item, current_context) + + traverse(data) + + # Convert defaultdict to a regular list of dictionaries + api_list = [{'className': class_name, 'apiFunctions': functions} for class_name, functions in api_structure.items()] + # Output the result as a JSON document + with open(output_json_path, 'w') as outfile: + json.dump(api_list, outfile, indent=2) + + print(f"Extracted public API. Output written to {output_json_path}") + + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python extract_public_api.py ") + sys.exit(1) + + input_api_json = sys.argv[1] + output_public_api_json = sys.argv[2] + + extract_public_api(input_api_json, output_public_api_json) diff --git a/scripts/generate_public_api.sh b/scripts/generate_public_api.sh new file mode 100755 index 00000000..e584adc9 --- /dev/null +++ b/scripts/generate_public_api.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +script_dir=$(dirname $0) +root_dir="$script_dir/../" + +# exit if param is not supplied +if [ -z "$1" ]; then + echo "Please provide the module name as a parameter." + exit 1 +fi +MODULE=$1 + +# Generate the json file with: +sourcekitten doc --module-name ${MODULE} -- -scheme Confidence-Package -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' > $script_dir/${MODULE}_raw_api.json + +# Extract the public API from the raw api json file +python3 $script_dir/extract_public_funcs.py $script_dir/${MODULE}_raw_api.json $root_dir/api/${MODULE}_public_api.json + +# Clean up the raw api json file +rm $script_dir/${MODULE}_raw_api.json \ No newline at end of file