Skip to content

Commit c6dee49

Browse files
authored
Add a command plugin to download AWS model files (#98)
1 parent aa25bbf commit c6dee49

9 files changed

Lines changed: 450 additions & 9 deletions

File tree

Package.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ let package = Package(
77
products: [
88
.executable(name: "SotoCodeGenerator", targets: ["SotoCodeGenerator"]),
99
.plugin(name: "SotoCodeGeneratorPlugin", targets: ["SotoCodeGeneratorPlugin"]),
10+
.plugin(name: "SotoModelDownloaderPlugin", targets: ["SotoModelDownloaderPlugin"]),
1011
],
1112
dependencies: [
1213
.package(url: "https://github.com/soto-project/soto-smithy.git", from: "0.4.7"),
@@ -23,6 +24,12 @@ let package = Package(
2324
.product(name: "Logging", package: "swift-log"),
2425
]
2526
),
27+
.executableTarget(
28+
name: "SotoModelDownloader",
29+
dependencies: [
30+
.product(name: "ArgumentParser", package: "swift-argument-parser")
31+
]
32+
),
2633
.target(
2734
name: "SotoCodeGeneratorLib",
2835
dependencies: [
@@ -37,6 +44,20 @@ let package = Package(
3744
capability: .buildTool(),
3845
dependencies: ["SotoCodeGenerator"]
3946
),
47+
.plugin(
48+
name: "SotoModelDownloaderPlugin",
49+
capability: .command(
50+
intent: .custom(
51+
verb: "download-aws-models",
52+
description: "Download AWS Smithy API model files for the Soto Code Generator"
53+
),
54+
permissions: [
55+
.allowNetworkConnections(scope: .all(ports: [443]), reason: "Download AWS Smithy API model files from GitHub"),
56+
.writeToPackageDirectory(reason: "Save AWS Smithy API models to target"),
57+
]
58+
),
59+
dependencies: ["SotoModelDownloader"]
60+
),
4061
.testTarget(
4162
name: "SotoCodeGeneratorTests",
4263
dependencies: ["SotoCodeGeneratorLib"]
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Soto for AWS open source project
4+
//
5+
// Copyright (c) 2026 the Soto project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Soto project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
import PackagePlugin
17+
18+
@main
19+
struct SotoModelDownloader: CommandPlugin {
20+
struct SotoModelDownloaderArguments {
21+
let outputFolder: String
22+
let configFile: String
23+
24+
var arguments: [String] {
25+
[
26+
"--config-file", configFile,
27+
"--output-folder", outputFolder,
28+
]
29+
}
30+
}
31+
32+
func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws {
33+
// Get the path to the SotoModelDownloader executable
34+
let sotoModelDownloaderTool = try context.tool(named: "SotoModelDownloader")
35+
let sotoModelDownloaderURL = URL(filePath: sotoModelDownloaderTool.path.string)
36+
37+
// Find the config file (soto.config.json) in the package targets
38+
let sourceTargetFiles = context.package.targets.compactMap { $0 as? SourceModuleTarget }
39+
let filePathArray = sourceTargetFiles.compactMap { target -> (SourceModuleTarget, Path)? in
40+
target.sourceFiles.first { $0.path.lastComponent.contains("soto.config.json") }.map {
41+
(target, $0.path)
42+
}
43+
}
44+
// Ensure a target with the config file is found
45+
guard filePathArray.count > 0 else {
46+
Diagnostics.error("Cannot find the soto.config.json file in the target")
47+
return
48+
}
49+
50+
for files in filePathArray {
51+
// Determine the main directory and output folder for the downloaded resources
52+
let mainDirectory = files.0.directory
53+
let outputFolderPath = mainDirectory.appending("aws-models").string
54+
55+
// Prepare arguments for downloading model files
56+
let downloaderArgs = SotoModelDownloaderArguments(
57+
outputFolder: outputFolderPath,
58+
configFile: files.1.string
59+
)
60+
61+
// Set up the process to run the SotoModelDownloader
62+
let process = Process()
63+
process.executableURL = sotoModelDownloaderURL
64+
process.arguments = downloaderArgs.arguments
65+
66+
// Run the process and wait for it to complete
67+
try process.run()
68+
process.waitUntilExit()
69+
70+
// Check the process termination status
71+
if process.terminationReason == .exit && process.terminationStatus == 0 {
72+
print("Downloaded resources to: \(outputFolderPath)")
73+
} else {
74+
let terminationDescription = "\(process.terminationReason):\(process.terminationStatus)"
75+
Diagnostics.error("SotoModelDownloader invocation failed: \(terminationDescription)")
76+
}
77+
}
78+
}
79+
}

README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ The Soto repository is very large and in general most projects only need a few o
1111
Replace your dependency on Soto in the Package.swift with the following
1212

1313
```swift
14-
.package(url: "https://github.com/soto-project/soto-codegenerator", from: "7.8.1"),
15-
.package(url: "https://github.com/soto-project/soto-core.git", from: "7.9.0"),
14+
.package(url: "https://github.com/soto-project/soto-codegenerator", from: "7.8.4"),
15+
.package(url: "https://github.com/soto-project/soto-core.git", from: "7.13.0"),
1616
```
1717

1818
And the target you want to add the generated Soto code to should be setup as follows
@@ -24,27 +24,44 @@ And the target you want to add the generated Soto code to should be setup as fol
2424
)
2525
```
2626

27-
You need a couple of files.
28-
- The region endpoint definition file [endpoints.json](https://aws-toolkit-endpoints.s3.amazonaws.com/endpoints.json). This file isn't totally necessary but if your service has custom endpoints you will need access to you will need it.
27+
Add a `soto.config.json` configuration file into the folder for the target you want to add AWS services. This is a json file that details which services you are using. An object is associated with each service. Later on we will discuss how this object can be used to control code generation.
28+
29+
```json
30+
{
31+
"services": {
32+
"s3": {},
33+
"iam": {}
34+
},
35+
}
36+
```
37+
38+
Code generation needs source model files to run.
39+
- The region endpoint definition file [endpoints.json](https://aws-toolkit-endpoints.s3.amazonaws.com/endpoints.json). This file isn't totally necessary but if your service has custom endpoints you will need access to it.
2940
- The model file for the service you want to use. You can find these in the [aws/api-models-aws](https://github.com/aws/api-models-aws/tree/main/models) repository.
3041

31-
The Build Plugin will search for the `endpoints.json` first in the root of your target and then the root of your project. The model file should be added to your target. Unless you are using a new region endpoint or new functionality from a service you shouldn't need to update these again.
42+
Both of these should be placed in your target folder. The easiest way to add these to your project is to run the command plugin `download-aws-models`.
43+
44+
```sh
45+
swift package plugin download-aws-models
46+
```
47+
48+
If your target only includes AWS Smithy model files in it, you need to add a dummy empty Swift file to the folder. Otherwise the Swift Package Manager will warn that you have no source files for your target and not build the target.
3249

33-
If your target only includes AWS Smithy model files in it, you need to add a dummy empty Swift file to the folder. Otherwise the Swift Package Manager will warn you have no source files for your target and not build the target.
50+
Now you can build your project.
3451

35-
Now when you build your target
3652
```
3753
swift build
3854
```
39-
The build plugin will put the generated Swift code in `.build/plugins/outputs/<package name>/<target name>/SotoCodeGeneratorPlugin/GeneratedSources` and will include it in the list of Swift files for that target.
55+
56+
The build plugin will put the generated Swift code in a sub-folder `.build/plugins/outputs/` and will include it in the list of Swift files for that target.
4057

4158
### Missing Code
4259

4360
If you are dependent on extension code from Soto (eg S3 multipart upload, STS/Cognito credential providers or DynamoDB Codable support), this process may not work for you as the extension code is not generated and will not be available to you.
4461

4562
## Config file
4663

47-
You can add a configuration file `soto.config.json` to your project to control the Swift code generation.
64+
It was mentioned above that the configuration file `soto.config.json` can control the Swift code generation.
4865

4966
Many of the services have a lot of operations which you may never use. If you are working in a low memory environment (eg an AWS Lambda or an iOS app)and only use `s3.getObject` why include code for the other 94 S3 operations in your code base. The configuration file can be used to filter the operations you generate code for. The following `soto.config.json` will only output code for `getObject`.
5067

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Soto for AWS open source project
4+
//
5+
// Copyright (c) 2026 the Soto project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Soto project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import ArgumentParser
16+
import Foundation
17+
18+
#if os(Linux)
19+
import FoundationNetworking
20+
#endif
21+
22+
@main
23+
struct App: AsyncParsableCommand {
24+
25+
/// The folder where the service files will be output.
26+
@Option(name: .long, help: "Folder to output service files to")
27+
var outputFolder: String
28+
29+
/// The configuration file, if provided.
30+
@Option(name: .long, help: "Code generator configuration file")
31+
var configFile: String
32+
33+
/// GitHub access token.
34+
@Option(name: [.short, .customLong("--gh-token")], help: "GitHub access token")
35+
var ghToken: String?
36+
37+
/// The main entry point of the command.
38+
func run() async throws {
39+
guard #available(macOS 13, *) else {
40+
fatalError("SotoModelDownloader requires macOS 13 or later")
41+
}
42+
43+
let downloader = try await Downloader.setup(ghToken: ghToken)
44+
let config = try await downloader.loadConfig(path: self.configFile)
45+
for service in config.services.keys {
46+
try await downloader.writeServiceFile(service, to: outputFolder)
47+
}
48+
try await downloader.writeEndpoints(to: outputFolder)
49+
}
50+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Soto for AWS open source project
4+
//
5+
// Copyright (c) 2026 the Soto project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Soto project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
17+
#if os(Linux)
18+
import FoundationNetworking
19+
#endif
20+
21+
@available(macOS 13, *)
22+
struct Downloader {
23+
let directory: GitHubTree
24+
let ghAPI: GitHubAPI
25+
26+
static func setup(
27+
ghToken: String?
28+
) async throws(DownloaderError) -> Downloader {
29+
let ghAPI = GitHubAPI(ghToken: ghToken)
30+
/// Get git commit hash from soto and download models using that hash
31+
let hash = try await getModelHash(path: "/soto-project/soto/refs/heads/main/.aws-model-hash", ghAPI: ghAPI)
32+
let directory = try await getAPIModelsAWS(hash: hash, ghAPI: ghAPI)
33+
return .init(directory: directory, ghAPI: ghAPI)
34+
}
35+
36+
/// Get current git hash for models used by Soto
37+
static func getModelHash(path: String, ghAPI: GitHubAPI) async throws(DownloaderError) -> String {
38+
let file = try await ghAPI.downloadRawFile(path: path)
39+
return String(decoding: file, as: UTF8.self).filter { !$0.isNewline && !$0.isWhitespace }
40+
}
41+
42+
/// Get api-models-aws tree
43+
static func getAPIModelsAWS(hash: String, ghAPI: GitHubAPI) async throws(DownloaderError) -> GitHubTree {
44+
// get https://github.com/aws/api-models-aws.git tree
45+
let data = try await ghAPI.callGitHubAPI(path: "/repos/aws/api-models-aws/git/trees/\(hash)?recursive=1")
46+
do {
47+
return try JSONDecoder().decode(GitHubTree.self, from: data)
48+
} catch {
49+
throw .failedToDecode("GitHub trees response", error)
50+
}
51+
}
52+
53+
/// Get GitHub resource, using URL from tree output
54+
private func getGitHubResource(url: URL) async throws(DownloaderError) -> String {
55+
do {
56+
let data = try await self.ghAPI.callGitHubAPI(url: url)
57+
let resource: GitHubResource
58+
do {
59+
resource = try JSONDecoder().decode(GitHubResource.self, from: data)
60+
} catch {
61+
throw .failedToDecode("GitHub trees response", error)
62+
}
63+
let encodedContent = resource.content.filter { !$0.isNewline && !$0.isWhitespace }
64+
guard let content = Data(base64Encoded: encodedContent) else {
65+
throw .failedToBase64Decode(url.absoluteString)
66+
}
67+
return String(decoding: content, as: UTF8.self)
68+
}
69+
}
70+
71+
func writeServiceFile(_ service: String, to folder: String) async throws(DownloaderError) {
72+
guard let entry = directory.tree.first(where: { $0.path.hasPrefix("models/\(service)/") && $0.path.hasSuffix(".json") }) else {
73+
throw .failedToFindService(service)
74+
}
75+
let fileContents = try await getGitHubResource(url: entry.url)
76+
let target = URL(fileURLWithPath: folder).appending(path: "\(service).json")
77+
do {
78+
try FileManager.default.createDirectory(atPath: folder, withIntermediateDirectories: true)
79+
print("Writing: \(target.relativePath)")
80+
try fileContents.write(to: target, atomically: true, encoding: .utf8)
81+
} catch {
82+
throw .failedToWriteFile(target.absoluteString)
83+
}
84+
}
85+
86+
func writeEndpoints(to folder: String) async throws(DownloaderError) {
87+
let endpoints = try await self.ghAPI.downloadRawFile(
88+
path:
89+
"/aws/aws-sdk-go-v2/refs/heads/main/codegen/smithy-aws-go-codegen/src/main/resources/software/amazon/smithy/aws/go/codegen/endpoints.json"
90+
)
91+
let target = URL(fileURLWithPath: folder).appending(path: "endpoints.json")
92+
do {
93+
print("Writing: \(target.relativePath)")
94+
try endpoints.write(to: target)
95+
} catch {
96+
throw .failedToWriteFile(target.absoluteString)
97+
}
98+
}
99+
100+
func loadConfig(path: String) async throws(DownloaderError) -> ConfigFile {
101+
let file: Data
102+
do {
103+
file = try Data(contentsOf: URL(fileURLWithPath: path))
104+
} catch {
105+
throw .failedToLoad(path)
106+
}
107+
do {
108+
return try JSONDecoder().decode(ConfigFile.self, from: file)
109+
} catch {
110+
throw .failedToDecode(path, error)
111+
}
112+
}
113+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Soto for AWS open source project
4+
//
5+
// Copyright (c) 2026 the Soto project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Soto project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
17+
enum DownloaderError: Error, CustomStringConvertible {
18+
case invalidURL(String)
19+
case failedToLoad(String)
20+
case failedToDecode(String, Error)
21+
case failedToBase64Decode(String)
22+
case failedToFindService(String)
23+
case failedToWriteFile(String)
24+
25+
var description: String {
26+
switch self {
27+
case .invalidURL(let url): "Invalid URL \(url)"
28+
case .failedToLoad(let url): "Failed to load \(url)"
29+
case .failedToDecode(let decoding, let error): "Failed to decode \(decoding). Error: \(error)"
30+
case .failedToBase64Decode(let url): "Failed to baset64 decode \(url)."
31+
case .failedToFindService(let service): "Failed to find service \(service) in API files"
32+
case .failedToWriteFile(let file): "Failed to write to \(file)"
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)