electron-cli 0.3.0-alpha.10 → 0.3.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +531 -5
- package/Cargo.toml +2 -1
- package/README.md +7 -9
- package/package.json +1 -1
- package/src/cli.rs +26 -0
- package/src/commands/plan.rs +1 -0
- package/src/commands/publish.rs +738 -44
- package/src/project.rs +45 -0
package/src/commands/publish.rs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
use std::{
|
|
2
2
|
fs,
|
|
3
|
+
fs::File,
|
|
3
4
|
path::{Path, PathBuf},
|
|
4
|
-
time::{SystemTime, UNIX_EPOCH},
|
|
5
|
+
time::{Duration, SystemTime, UNIX_EPOCH},
|
|
5
6
|
};
|
|
6
7
|
|
|
7
8
|
use anyhow::{bail, Context, Result};
|
|
8
9
|
use camino::Utf8PathBuf;
|
|
9
|
-
use serde::Serialize;
|
|
10
|
+
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
|
10
11
|
|
|
11
12
|
use crate::{
|
|
12
|
-
cli::{MakeArgs, PublishArgs},
|
|
13
|
+
cli::{MakeArgs, PublishArgs, PublishTarget},
|
|
13
14
|
commands::make::{self, MakeReport},
|
|
14
15
|
output,
|
|
15
16
|
};
|
|
@@ -19,9 +20,8 @@ struct PublishReport {
|
|
|
19
20
|
make: MakeReport,
|
|
20
21
|
publisher: String,
|
|
21
22
|
channel: String,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
manifest: Utf8PathBuf,
|
|
23
|
+
local: Option<LocalPublishPlan>,
|
|
24
|
+
github: Option<GithubPublishPlan>,
|
|
25
25
|
skip_make: bool,
|
|
26
26
|
dry_run: bool,
|
|
27
27
|
status: PublishStatus,
|
|
@@ -29,6 +29,26 @@ struct PublishReport {
|
|
|
29
29
|
warnings: Vec<String>,
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
#[derive(Debug, Serialize)]
|
|
33
|
+
struct LocalPublishPlan {
|
|
34
|
+
destination_dir: Utf8PathBuf,
|
|
35
|
+
destination_artifact: Utf8PathBuf,
|
|
36
|
+
manifest: Utf8PathBuf,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Serialize)]
|
|
40
|
+
struct GithubPublishPlan {
|
|
41
|
+
repo: String,
|
|
42
|
+
tag: String,
|
|
43
|
+
release_name: String,
|
|
44
|
+
draft: bool,
|
|
45
|
+
prerelease: bool,
|
|
46
|
+
api_url: String,
|
|
47
|
+
artifact_name: String,
|
|
48
|
+
release_url: Option<String>,
|
|
49
|
+
asset_url: Option<String>,
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
#[derive(Debug, Serialize)]
|
|
33
53
|
#[serde(rename_all = "kebab-case")]
|
|
34
54
|
enum PublishStatus {
|
|
@@ -86,17 +106,11 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
|
|
|
86
106
|
};
|
|
87
107
|
let make = make::build_report(&make_args)?;
|
|
88
108
|
let root = Path::new(make.package().project().root.as_str());
|
|
89
|
-
let publish_root = resolve_destination(root, &args.to);
|
|
90
|
-
let destination_dir = publish_root
|
|
91
|
-
.join(&args.channel)
|
|
92
|
-
.join(make.package().platform())
|
|
93
|
-
.join(make.package().arch());
|
|
94
109
|
let artifact_name = make
|
|
95
110
|
.artifact()
|
|
96
111
|
.file_name()
|
|
97
|
-
.context("Make artifact path has no file name")
|
|
98
|
-
|
|
99
|
-
let manifest = destination_dir.join("manifest.json");
|
|
112
|
+
.context("Make artifact path has no UTF-8 file name")?
|
|
113
|
+
.to_string();
|
|
100
114
|
|
|
101
115
|
let mut warnings = make.warnings().to_vec();
|
|
102
116
|
if args.skip_make && !Path::new(make.artifact().as_str()).exists() {
|
|
@@ -105,26 +119,35 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
|
|
|
105
119
|
make.artifact()
|
|
106
120
|
));
|
|
107
121
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
destination_artifact.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
manifest.
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
let (local, github) = match args.publisher {
|
|
123
|
+
PublishTarget::Local => {
|
|
124
|
+
let local = build_local_plan(root, args, &make, &artifact_name)?;
|
|
125
|
+
if Path::new(local.destination_artifact.as_str()).exists() && !args.force {
|
|
126
|
+
warnings.push(format!(
|
|
127
|
+
"Publish artifact already exists: {}. Use --force to overwrite it.",
|
|
128
|
+
local.destination_artifact
|
|
129
|
+
));
|
|
130
|
+
}
|
|
131
|
+
if Path::new(local.manifest.as_str()).exists() && !args.force {
|
|
132
|
+
warnings.push(format!(
|
|
133
|
+
"Publish manifest already exists: {}. Use --force to overwrite it.",
|
|
134
|
+
local.manifest
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
(Some(local), None)
|
|
138
|
+
}
|
|
139
|
+
PublishTarget::Github => {
|
|
140
|
+
let github = build_github_plan(args, &make, &artifact_name, &mut warnings)?;
|
|
141
|
+
(None, Some(github))
|
|
142
|
+
}
|
|
143
|
+
};
|
|
120
144
|
|
|
121
145
|
Ok(PublishReport {
|
|
122
146
|
make,
|
|
123
147
|
publisher: args.publisher.as_str().to_string(),
|
|
124
148
|
channel: args.channel.clone(),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
manifest: utf8_path(manifest)?,
|
|
149
|
+
local,
|
|
150
|
+
github,
|
|
128
151
|
skip_make: args.skip_make,
|
|
129
152
|
dry_run: args.dry_run,
|
|
130
153
|
status: PublishStatus::Planned,
|
|
@@ -133,6 +156,77 @@ fn build_report(args: &PublishArgs) -> Result<PublishReport> {
|
|
|
133
156
|
})
|
|
134
157
|
}
|
|
135
158
|
|
|
159
|
+
fn build_local_plan(
|
|
160
|
+
root: &Path,
|
|
161
|
+
args: &PublishArgs,
|
|
162
|
+
make: &MakeReport,
|
|
163
|
+
artifact_name: &str,
|
|
164
|
+
) -> Result<LocalPublishPlan> {
|
|
165
|
+
let publish_root = resolve_destination(root, &args.to);
|
|
166
|
+
let destination_dir = publish_root
|
|
167
|
+
.join(&args.channel)
|
|
168
|
+
.join(make.package().platform())
|
|
169
|
+
.join(make.package().arch());
|
|
170
|
+
let destination_artifact = destination_dir.join(artifact_name);
|
|
171
|
+
let manifest = destination_dir.join("manifest.json");
|
|
172
|
+
|
|
173
|
+
Ok(LocalPublishPlan {
|
|
174
|
+
destination_dir: utf8_path(destination_dir)?,
|
|
175
|
+
destination_artifact: utf8_path(destination_artifact)?,
|
|
176
|
+
manifest: utf8_path(manifest)?,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn build_github_plan(
|
|
181
|
+
args: &PublishArgs,
|
|
182
|
+
make: &MakeReport,
|
|
183
|
+
artifact_name: &str,
|
|
184
|
+
warnings: &mut Vec<String>,
|
|
185
|
+
) -> Result<GithubPublishPlan> {
|
|
186
|
+
let repo = args
|
|
187
|
+
.github_repo
|
|
188
|
+
.clone()
|
|
189
|
+
.or_else(|| {
|
|
190
|
+
make.package()
|
|
191
|
+
.project()
|
|
192
|
+
.repository
|
|
193
|
+
.as_deref()
|
|
194
|
+
.and_then(github_repo_from_repository)
|
|
195
|
+
})
|
|
196
|
+
.unwrap_or_default();
|
|
197
|
+
if repo.is_empty() {
|
|
198
|
+
warnings.push(
|
|
199
|
+
"GitHub repository is not configured. Pass --github-repo OWNER/REPO or set package.json repository.".to_string(),
|
|
200
|
+
);
|
|
201
|
+
} else if !valid_github_repo(&repo) {
|
|
202
|
+
warnings.push(format!(
|
|
203
|
+
"GitHub repository should use OWNER/REPO form: {repo}."
|
|
204
|
+
));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let tag = args
|
|
208
|
+
.github_tag
|
|
209
|
+
.clone()
|
|
210
|
+
.unwrap_or_else(|| default_github_tag(make, &args.channel));
|
|
211
|
+
let release_name = args
|
|
212
|
+
.github_release_name
|
|
213
|
+
.clone()
|
|
214
|
+
.unwrap_or_else(|| tag.clone());
|
|
215
|
+
let prerelease = args.github_prerelease || tag.contains('-');
|
|
216
|
+
|
|
217
|
+
Ok(GithubPublishPlan {
|
|
218
|
+
repo,
|
|
219
|
+
tag,
|
|
220
|
+
release_name,
|
|
221
|
+
draft: args.github_draft,
|
|
222
|
+
prerelease,
|
|
223
|
+
api_url: args.github_api_url.trim_end_matches('/').to_string(),
|
|
224
|
+
artifact_name: artifact_name.to_string(),
|
|
225
|
+
release_url: None,
|
|
226
|
+
asset_url: None,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
136
230
|
fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
|
|
137
231
|
if !args.skip_make {
|
|
138
232
|
let make_args = MakeArgs {
|
|
@@ -156,8 +250,26 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
|
|
|
156
250
|
);
|
|
157
251
|
}
|
|
158
252
|
|
|
159
|
-
let
|
|
160
|
-
|
|
253
|
+
let published_at_unix_seconds = now_unix_seconds()?;
|
|
254
|
+
report.published_at_unix_seconds = Some(published_at_unix_seconds);
|
|
255
|
+
|
|
256
|
+
match args.publisher {
|
|
257
|
+
PublishTarget::Local => execute_local_publish(report, args, published_at_unix_seconds),
|
|
258
|
+
PublishTarget::Github => execute_github_publish(report, args),
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn execute_local_publish(
|
|
263
|
+
report: &PublishReport,
|
|
264
|
+
args: &PublishArgs,
|
|
265
|
+
published_at_unix_seconds: u64,
|
|
266
|
+
) -> Result<()> {
|
|
267
|
+
let local = report
|
|
268
|
+
.local
|
|
269
|
+
.as_ref()
|
|
270
|
+
.context("Local publish plan was not built")?;
|
|
271
|
+
let destination_artifact = Path::new(local.destination_artifact.as_str());
|
|
272
|
+
let manifest = Path::new(local.manifest.as_str());
|
|
161
273
|
|
|
162
274
|
for path in [destination_artifact, manifest] {
|
|
163
275
|
if path.exists() {
|
|
@@ -173,8 +285,8 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
|
|
|
173
285
|
}
|
|
174
286
|
}
|
|
175
287
|
|
|
176
|
-
fs::create_dir_all(
|
|
177
|
-
.with_context(|| format!("Could not create {}",
|
|
288
|
+
fs::create_dir_all(local.destination_dir.as_str())
|
|
289
|
+
.with_context(|| format!("Could not create {}", local.destination_dir))?;
|
|
178
290
|
fs::copy(report.make.artifact().as_str(), destination_artifact).with_context(|| {
|
|
179
291
|
format!(
|
|
180
292
|
"Could not publish {} to {}",
|
|
@@ -183,8 +295,6 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
|
|
|
183
295
|
)
|
|
184
296
|
})?;
|
|
185
297
|
|
|
186
|
-
let published_at_unix_seconds = now_unix_seconds()?;
|
|
187
|
-
report.published_at_unix_seconds = Some(published_at_unix_seconds);
|
|
188
298
|
let manifest_json =
|
|
189
299
|
serde_json::to_string_pretty(&build_manifest(report, published_at_unix_seconds)?)?;
|
|
190
300
|
fs::write(manifest, format!("{manifest_json}\n"))
|
|
@@ -193,11 +303,229 @@ fn execute_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()>
|
|
|
193
303
|
Ok(())
|
|
194
304
|
}
|
|
195
305
|
|
|
306
|
+
fn execute_github_publish(report: &mut PublishReport, args: &PublishArgs) -> Result<()> {
|
|
307
|
+
let token = github_token()?;
|
|
308
|
+
let agent = github_agent();
|
|
309
|
+
publish_to_github(report, args, &token, &agent)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#[derive(Debug, Serialize)]
|
|
313
|
+
struct CreateGithubRelease<'a> {
|
|
314
|
+
tag_name: &'a str,
|
|
315
|
+
name: &'a str,
|
|
316
|
+
body: &'a str,
|
|
317
|
+
draft: bool,
|
|
318
|
+
prerelease: bool,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#[derive(Debug, Deserialize)]
|
|
322
|
+
struct GithubRelease {
|
|
323
|
+
html_url: String,
|
|
324
|
+
upload_url: String,
|
|
325
|
+
#[serde(default)]
|
|
326
|
+
assets: Vec<GithubAsset>,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[derive(Debug, Deserialize)]
|
|
330
|
+
struct GithubAsset {
|
|
331
|
+
id: u64,
|
|
332
|
+
name: String,
|
|
333
|
+
browser_download_url: String,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#[derive(Debug, Deserialize)]
|
|
337
|
+
struct GithubErrorBody {
|
|
338
|
+
message: Option<String>,
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
fn publish_to_github(
|
|
342
|
+
report: &mut PublishReport,
|
|
343
|
+
args: &PublishArgs,
|
|
344
|
+
token: &str,
|
|
345
|
+
agent: &ureq::Agent,
|
|
346
|
+
) -> Result<()> {
|
|
347
|
+
let artifact_path = Path::new(report.make.artifact().as_str());
|
|
348
|
+
let github = report
|
|
349
|
+
.github
|
|
350
|
+
.as_mut()
|
|
351
|
+
.context("GitHub publish plan was not built")?;
|
|
352
|
+
if github.repo.is_empty() || !valid_github_repo(&github.repo) {
|
|
353
|
+
bail!("GitHub repository must be configured as OWNER/REPO.");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let release = get_or_create_github_release(agent, token, github)?;
|
|
357
|
+
if let Some(asset) = release
|
|
358
|
+
.assets
|
|
359
|
+
.iter()
|
|
360
|
+
.find(|asset| asset.name == github.artifact_name)
|
|
361
|
+
{
|
|
362
|
+
if args.force {
|
|
363
|
+
delete_github_asset(agent, token, github, asset.id)?;
|
|
364
|
+
} else {
|
|
365
|
+
bail!(
|
|
366
|
+
"GitHub release asset already exists: {}. Use --force to replace it.",
|
|
367
|
+
github.artifact_name
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let asset = upload_github_asset(agent, token, github, &release.upload_url, artifact_path)?;
|
|
373
|
+
github.release_url = Some(release.html_url);
|
|
374
|
+
github.asset_url = Some(asset.browser_download_url);
|
|
375
|
+
|
|
376
|
+
Ok(())
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
fn get_or_create_github_release(
|
|
380
|
+
agent: &ureq::Agent,
|
|
381
|
+
token: &str,
|
|
382
|
+
github: &GithubPublishPlan,
|
|
383
|
+
) -> Result<GithubRelease> {
|
|
384
|
+
let get_url = format!(
|
|
385
|
+
"{}/repos/{}/releases/tags/{}",
|
|
386
|
+
github.api_url,
|
|
387
|
+
github.repo,
|
|
388
|
+
encode_url_component(&github.tag)
|
|
389
|
+
);
|
|
390
|
+
let response = github_request(agent.get(&get_url), token)
|
|
391
|
+
.call()
|
|
392
|
+
.with_context(|| format!("Could not query GitHub release {}", github.tag))?;
|
|
393
|
+
match response.status().as_u16() {
|
|
394
|
+
200 => parse_github_response(response, "Could not parse GitHub release"),
|
|
395
|
+
404 => create_github_release(agent, token, github),
|
|
396
|
+
status => github_status_error(response, status, "Could not query GitHub release"),
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fn create_github_release(
|
|
401
|
+
agent: &ureq::Agent,
|
|
402
|
+
token: &str,
|
|
403
|
+
github: &GithubPublishPlan,
|
|
404
|
+
) -> Result<GithubRelease> {
|
|
405
|
+
let url = format!("{}/repos/{}/releases", github.api_url, github.repo);
|
|
406
|
+
let body = format!(
|
|
407
|
+
"{} {} {} artifact published by electron-cli.",
|
|
408
|
+
github.artifact_name, github.tag, github.repo
|
|
409
|
+
);
|
|
410
|
+
let request = CreateGithubRelease {
|
|
411
|
+
tag_name: &github.tag,
|
|
412
|
+
name: &github.release_name,
|
|
413
|
+
body: &body,
|
|
414
|
+
draft: github.draft,
|
|
415
|
+
prerelease: github.prerelease,
|
|
416
|
+
};
|
|
417
|
+
let response = github_request(agent.post(&url), token)
|
|
418
|
+
.content_type("application/json")
|
|
419
|
+
.send_json(&request)
|
|
420
|
+
.with_context(|| format!("Could not create GitHub release {}", github.tag))?;
|
|
421
|
+
let status = response.status().as_u16();
|
|
422
|
+
if status == 201 {
|
|
423
|
+
parse_github_response(response, "Could not parse created GitHub release")
|
|
424
|
+
} else {
|
|
425
|
+
github_status_error(response, status, "Could not create GitHub release")
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
fn delete_github_asset(
|
|
430
|
+
agent: &ureq::Agent,
|
|
431
|
+
token: &str,
|
|
432
|
+
github: &GithubPublishPlan,
|
|
433
|
+
asset_id: u64,
|
|
434
|
+
) -> Result<()> {
|
|
435
|
+
let url = format!(
|
|
436
|
+
"{}/repos/{}/releases/assets/{}",
|
|
437
|
+
github.api_url, github.repo, asset_id
|
|
438
|
+
);
|
|
439
|
+
let response = github_request(agent.delete(&url), token)
|
|
440
|
+
.call()
|
|
441
|
+
.with_context(|| format!("Could not delete GitHub release asset {asset_id}"))?;
|
|
442
|
+
let status = response.status().as_u16();
|
|
443
|
+
if status == 204 {
|
|
444
|
+
Ok(())
|
|
445
|
+
} else {
|
|
446
|
+
github_status_error(response, status, "Could not delete GitHub release asset")
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fn upload_github_asset(
|
|
451
|
+
agent: &ureq::Agent,
|
|
452
|
+
token: &str,
|
|
453
|
+
github: &GithubPublishPlan,
|
|
454
|
+
upload_url: &str,
|
|
455
|
+
artifact_path: &Path,
|
|
456
|
+
) -> Result<GithubAsset> {
|
|
457
|
+
let url = github_asset_upload_url(upload_url, &github.artifact_name);
|
|
458
|
+
let file = File::open(artifact_path)
|
|
459
|
+
.with_context(|| format!("Could not open {}", artifact_path.display()))?;
|
|
460
|
+
let response = github_request(agent.post(&url), token)
|
|
461
|
+
.content_type("application/octet-stream")
|
|
462
|
+
.send(file)
|
|
463
|
+
.with_context(|| format!("Could not upload {}", artifact_path.display()))?;
|
|
464
|
+
let status = response.status().as_u16();
|
|
465
|
+
if status == 201 {
|
|
466
|
+
parse_github_response(response, "Could not parse uploaded GitHub asset")
|
|
467
|
+
} else {
|
|
468
|
+
github_status_error(response, status, "Could not upload GitHub release asset")
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
fn github_request<T>(request: ureq::RequestBuilder<T>, token: &str) -> ureq::RequestBuilder<T> {
|
|
473
|
+
request
|
|
474
|
+
.header("Accept", "application/vnd.github+json")
|
|
475
|
+
.header("Authorization", format!("Bearer {token}"))
|
|
476
|
+
.header(
|
|
477
|
+
"User-Agent",
|
|
478
|
+
format!("electron-cli/{}", env!("CARGO_PKG_VERSION")),
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fn parse_github_response<T: DeserializeOwned>(
|
|
483
|
+
mut response: ureq::http::Response<ureq::Body>,
|
|
484
|
+
context: &str,
|
|
485
|
+
) -> Result<T> {
|
|
486
|
+
response
|
|
487
|
+
.body_mut()
|
|
488
|
+
.read_json::<T>()
|
|
489
|
+
.with_context(|| context.to_string())
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
fn github_status_error<T>(
|
|
493
|
+
mut response: ureq::http::Response<ureq::Body>,
|
|
494
|
+
status: u16,
|
|
495
|
+
context: &str,
|
|
496
|
+
) -> Result<T> {
|
|
497
|
+
let body = response.body_mut().read_to_string().unwrap_or_default();
|
|
498
|
+
let message = serde_json::from_str::<GithubErrorBody>(&body)
|
|
499
|
+
.ok()
|
|
500
|
+
.and_then(|body| body.message)
|
|
501
|
+
.filter(|message| !message.trim().is_empty())
|
|
502
|
+
.unwrap_or(body);
|
|
503
|
+
bail!("{context}: HTTP {status}: {message}")
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
fn github_agent() -> ureq::Agent {
|
|
507
|
+
ureq::Agent::config_builder()
|
|
508
|
+
.http_status_as_error(false)
|
|
509
|
+
.timeout_global(Some(Duration::from_secs(300)))
|
|
510
|
+
.build()
|
|
511
|
+
.into()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
fn github_token() -> Result<String> {
|
|
515
|
+
std::env::var("GITHUB_TOKEN")
|
|
516
|
+
.or_else(|_| std::env::var("GH_TOKEN"))
|
|
517
|
+
.context("GitHub publisher requires GITHUB_TOKEN or GH_TOKEN")
|
|
518
|
+
}
|
|
519
|
+
|
|
196
520
|
fn build_manifest(
|
|
197
521
|
report: &PublishReport,
|
|
198
522
|
published_at_unix_seconds: u64,
|
|
199
523
|
) -> Result<PublishManifest> {
|
|
200
|
-
let
|
|
524
|
+
let local = report
|
|
525
|
+
.local
|
|
526
|
+
.as_ref()
|
|
527
|
+
.context("Local publish manifest requires a local publish plan")?;
|
|
528
|
+
let destination_artifact = Path::new(local.destination_artifact.as_str());
|
|
201
529
|
let artifact_size = fs::metadata(destination_artifact)
|
|
202
530
|
.with_context(|| format!("Could not stat {}", destination_artifact.display()))?
|
|
203
531
|
.len();
|
|
@@ -220,7 +548,7 @@ fn build_manifest(
|
|
|
220
548
|
published_at_unix_seconds,
|
|
221
549
|
artifacts: vec![PublishedArtifact {
|
|
222
550
|
file: artifact_file,
|
|
223
|
-
path:
|
|
551
|
+
path: local.destination_artifact.clone(),
|
|
224
552
|
size: artifact_size,
|
|
225
553
|
}],
|
|
226
554
|
})
|
|
@@ -252,8 +580,22 @@ fn print_report(report: &PublishReport, json: bool) -> Result<()> {
|
|
|
252
580
|
|
|
253
581
|
println!();
|
|
254
582
|
println!("Publish");
|
|
255
|
-
|
|
256
|
-
|
|
583
|
+
if let Some(local) = &report.local {
|
|
584
|
+
println!(" artifact: {}", local.destination_artifact);
|
|
585
|
+
println!(" manifest: {}", local.manifest);
|
|
586
|
+
}
|
|
587
|
+
if let Some(github) = &report.github {
|
|
588
|
+
println!(" repository: {}", github.repo);
|
|
589
|
+
println!(" tag: {}", github.tag);
|
|
590
|
+
println!(" release: {}", github.release_name);
|
|
591
|
+
println!(" artifact: {}", github.artifact_name);
|
|
592
|
+
if let Some(url) = &github.release_url {
|
|
593
|
+
println!(" release url: {url}");
|
|
594
|
+
}
|
|
595
|
+
if let Some(url) = &github.asset_url {
|
|
596
|
+
println!(" asset url: {url}");
|
|
597
|
+
}
|
|
598
|
+
}
|
|
257
599
|
|
|
258
600
|
if !report.warnings.is_empty() {
|
|
259
601
|
println!();
|
|
@@ -274,6 +616,80 @@ fn resolve_destination(root: &Path, destination: &Path) -> PathBuf {
|
|
|
274
616
|
}
|
|
275
617
|
}
|
|
276
618
|
|
|
619
|
+
fn default_github_tag(make: &MakeReport, channel: &str) -> String {
|
|
620
|
+
make.package()
|
|
621
|
+
.project()
|
|
622
|
+
.version
|
|
623
|
+
.as_deref()
|
|
624
|
+
.map(|version| {
|
|
625
|
+
if version.starts_with('v') {
|
|
626
|
+
version.to_string()
|
|
627
|
+
} else {
|
|
628
|
+
format!("v{version}")
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
.unwrap_or_else(|| channel.to_string())
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
fn valid_github_repo(repo: &str) -> bool {
|
|
635
|
+
let mut parts = repo.split('/');
|
|
636
|
+
let Some(owner) = parts.next() else {
|
|
637
|
+
return false;
|
|
638
|
+
};
|
|
639
|
+
let Some(name) = parts.next() else {
|
|
640
|
+
return false;
|
|
641
|
+
};
|
|
642
|
+
parts.next().is_none() && valid_github_path_part(owner) && valid_github_path_part(name)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
fn valid_github_path_part(part: &str) -> bool {
|
|
646
|
+
!part.is_empty()
|
|
647
|
+
&& part
|
|
648
|
+
.chars()
|
|
649
|
+
.all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.'))
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
fn github_repo_from_repository(repository: &str) -> Option<String> {
|
|
653
|
+
let mut value = repository.trim().trim_start_matches("git+").to_string();
|
|
654
|
+
if let Some(fragment) = value.find('#') {
|
|
655
|
+
value.truncate(fragment);
|
|
656
|
+
}
|
|
657
|
+
let value = value.trim_end_matches(".git");
|
|
658
|
+
let path = value
|
|
659
|
+
.split_once("github.com:")
|
|
660
|
+
.map(|(_, path)| path)
|
|
661
|
+
.or_else(|| value.split_once("github.com/").map(|(_, path)| path))?;
|
|
662
|
+
let mut parts = path.trim_matches('/').split('/');
|
|
663
|
+
let owner = parts.next()?.trim();
|
|
664
|
+
let repo = parts.next()?.trim();
|
|
665
|
+
if owner.is_empty() || repo.is_empty() {
|
|
666
|
+
None
|
|
667
|
+
} else {
|
|
668
|
+
Some(format!("{owner}/{repo}"))
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
fn github_asset_upload_url(upload_url: &str, artifact_name: &str) -> String {
|
|
673
|
+
let base = upload_url.split('{').next().unwrap_or(upload_url);
|
|
674
|
+
let separator = if base.contains('?') { '&' } else { '?' };
|
|
675
|
+
format!(
|
|
676
|
+
"{base}{separator}name={}",
|
|
677
|
+
encode_url_component(artifact_name)
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
fn encode_url_component(value: &str) -> String {
|
|
682
|
+
let mut encoded = String::new();
|
|
683
|
+
for byte in value.bytes() {
|
|
684
|
+
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
|
685
|
+
encoded.push(byte as char);
|
|
686
|
+
} else {
|
|
687
|
+
encoded.push_str(&format!("%{byte:02X}"));
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
encoded
|
|
691
|
+
}
|
|
692
|
+
|
|
277
693
|
fn now_unix_seconds() -> Result<u64> {
|
|
278
694
|
Ok(SystemTime::now()
|
|
279
695
|
.duration_since(UNIX_EPOCH)
|
|
@@ -302,6 +718,12 @@ impl PublishStatus {
|
|
|
302
718
|
#[cfg(test)]
|
|
303
719
|
mod tests {
|
|
304
720
|
use super::*;
|
|
721
|
+
use std::{
|
|
722
|
+
io::{Read, Write},
|
|
723
|
+
net::{TcpListener, TcpStream},
|
|
724
|
+
sync::{Arc, Mutex},
|
|
725
|
+
thread,
|
|
726
|
+
};
|
|
305
727
|
|
|
306
728
|
#[test]
|
|
307
729
|
fn builds_local_publish_report() {
|
|
@@ -315,7 +737,8 @@ mod tests {
|
|
|
315
737
|
|
|
316
738
|
assert_eq!(report.publisher, "local");
|
|
317
739
|
assert_eq!(report.channel, "default");
|
|
318
|
-
|
|
740
|
+
let local = report.local.as_ref().expect("local plan should exist");
|
|
741
|
+
assert!(Path::new(local.destination_artifact.as_str()).ends_with(
|
|
319
742
|
PathBuf::from("out")
|
|
320
743
|
.join("publish")
|
|
321
744
|
.join("local")
|
|
@@ -344,16 +767,103 @@ mod tests {
|
|
|
344
767
|
|
|
345
768
|
execute_publish(&mut report, &args).expect("publish should succeed");
|
|
346
769
|
|
|
347
|
-
|
|
348
|
-
assert!(Path::new(
|
|
349
|
-
|
|
350
|
-
|
|
770
|
+
let local = report.local.as_ref().expect("local plan should exist");
|
|
771
|
+
assert!(Path::new(local.destination_artifact.as_str()).exists());
|
|
772
|
+
assert!(Path::new(local.manifest.as_str()).exists());
|
|
773
|
+
let manifest = fs::read_to_string(local.manifest.as_str()).expect("manifest should read");
|
|
351
774
|
assert!(manifest.contains("\"publisher\": \"local\""));
|
|
352
775
|
assert!(manifest.contains("\"app_name\": \"starter-app\""));
|
|
353
776
|
|
|
354
777
|
let _ = fs::remove_dir_all(root);
|
|
355
778
|
}
|
|
356
779
|
|
|
780
|
+
#[test]
|
|
781
|
+
fn builds_github_publish_report_from_package_repository() {
|
|
782
|
+
let root = unique_temp_dir("github-plan");
|
|
783
|
+
write_github_package_json(&root);
|
|
784
|
+
write_app_file(&root);
|
|
785
|
+
write_fake_electron_dist(&root);
|
|
786
|
+
|
|
787
|
+
let args = github_publish_args(root.clone(), true, "http://127.0.0.1:9");
|
|
788
|
+
let report = build_report(&args).expect("report should build");
|
|
789
|
+
|
|
790
|
+
assert_eq!(report.publisher, "github");
|
|
791
|
+
assert!(report.local.is_none());
|
|
792
|
+
let github = report.github.as_ref().expect("github plan should exist");
|
|
793
|
+
assert_eq!(github.repo, "Ikana/electron-cli");
|
|
794
|
+
assert_eq!(github.tag, "v0.1.0");
|
|
795
|
+
assert_eq!(github.release_name, "v0.1.0");
|
|
796
|
+
assert!(!github.draft);
|
|
797
|
+
assert!(!github.prerelease);
|
|
798
|
+
assert_eq!(
|
|
799
|
+
github.artifact_name,
|
|
800
|
+
format!(
|
|
801
|
+
"starter-app-{}-{}.zip",
|
|
802
|
+
report.make.package().platform(),
|
|
803
|
+
report.make.package().arch()
|
|
804
|
+
)
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
let _ = fs::remove_dir_all(root);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
#[test]
|
|
811
|
+
fn publishes_make_artifact_to_github_release() {
|
|
812
|
+
let server = MockGithubServer::new(3);
|
|
813
|
+
let root = unique_temp_dir("github-execute");
|
|
814
|
+
write_github_package_json(&root);
|
|
815
|
+
write_app_file(&root);
|
|
816
|
+
write_fake_electron_dist(&root);
|
|
817
|
+
|
|
818
|
+
let mut args = github_publish_args(root.clone(), false, &server.api_url);
|
|
819
|
+
args.skip_make = true;
|
|
820
|
+
let mut report = build_report(&args).expect("report should build");
|
|
821
|
+
let artifact = Path::new(report.make.artifact().as_str());
|
|
822
|
+
fs::create_dir_all(artifact.parent().expect("artifact parent should exist"))
|
|
823
|
+
.expect("artifact parent should be created");
|
|
824
|
+
fs::write(artifact, b"artifact bytes").expect("artifact should be written");
|
|
825
|
+
|
|
826
|
+
let agent = github_agent();
|
|
827
|
+
publish_to_github(&mut report, &args, "test-token", &agent)
|
|
828
|
+
.expect("github publish should succeed");
|
|
829
|
+
|
|
830
|
+
let github = report.github.as_ref().expect("github plan should exist");
|
|
831
|
+
let artifact_name = github.artifact_name.clone();
|
|
832
|
+
assert_eq!(
|
|
833
|
+
github.release_url.as_deref(),
|
|
834
|
+
Some("https://github.com/Ikana/electron-cli/releases/tag/v0.1.0")
|
|
835
|
+
);
|
|
836
|
+
assert_eq!(
|
|
837
|
+
github.asset_url.as_deref(),
|
|
838
|
+
Some("https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip")
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
let requests = server.finish();
|
|
842
|
+
assert_eq!(requests.len(), 3);
|
|
843
|
+
assert_eq!(requests[0].method, "GET");
|
|
844
|
+
assert_eq!(
|
|
845
|
+
requests[0].path,
|
|
846
|
+
"/repos/Ikana/electron-cli/releases/tags/v0.1.0"
|
|
847
|
+
);
|
|
848
|
+
assert_eq!(
|
|
849
|
+
requests[0].header("authorization").as_deref(),
|
|
850
|
+
Some("Bearer test-token")
|
|
851
|
+
);
|
|
852
|
+
assert_eq!(requests[1].method, "POST");
|
|
853
|
+
assert_eq!(requests[1].path, "/repos/Ikana/electron-cli/releases");
|
|
854
|
+
let release_body =
|
|
855
|
+
String::from_utf8(requests[1].body.clone()).expect("release body should be utf-8");
|
|
856
|
+
assert!(release_body.contains("\"tag_name\": \"v0.1.0\""));
|
|
857
|
+
assert_eq!(requests[2].method, "POST");
|
|
858
|
+
assert_eq!(
|
|
859
|
+
requests[2].path,
|
|
860
|
+
format!("/uploads/1?name={}", encode_url_component(&artifact_name))
|
|
861
|
+
);
|
|
862
|
+
assert_eq!(requests[2].body, b"artifact bytes");
|
|
863
|
+
|
|
864
|
+
let _ = fs::remove_dir_all(root);
|
|
865
|
+
}
|
|
866
|
+
|
|
357
867
|
#[test]
|
|
358
868
|
fn skip_make_requires_existing_artifact() {
|
|
359
869
|
let root = unique_temp_dir("skip-make");
|
|
@@ -370,6 +880,22 @@ mod tests {
|
|
|
370
880
|
let _ = fs::remove_dir_all(root);
|
|
371
881
|
}
|
|
372
882
|
|
|
883
|
+
#[test]
|
|
884
|
+
fn parses_github_repository_urls() {
|
|
885
|
+
assert_eq!(
|
|
886
|
+
github_repo_from_repository("git+https://github.com/Ikana/electron-cli.git"),
|
|
887
|
+
Some("Ikana/electron-cli".to_string())
|
|
888
|
+
);
|
|
889
|
+
assert_eq!(
|
|
890
|
+
github_repo_from_repository("git@github.com:Ikana/electron-cli.git"),
|
|
891
|
+
Some("Ikana/electron-cli".to_string())
|
|
892
|
+
);
|
|
893
|
+
assert_eq!(
|
|
894
|
+
github_repo_from_repository("https://example.com/Ikana/electron-cli.git"),
|
|
895
|
+
None
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
373
899
|
fn publish_args(root: PathBuf, dry_run: bool) -> PublishArgs {
|
|
374
900
|
PublishArgs {
|
|
375
901
|
cwd: root,
|
|
@@ -380,6 +906,12 @@ mod tests {
|
|
|
380
906
|
target: crate::cli::MakeTarget::Zip,
|
|
381
907
|
publisher: crate::cli::PublishTarget::Local,
|
|
382
908
|
to: PathBuf::from("out/publish/local"),
|
|
909
|
+
github_repo: None,
|
|
910
|
+
github_tag: None,
|
|
911
|
+
github_release_name: None,
|
|
912
|
+
github_draft: false,
|
|
913
|
+
github_prerelease: false,
|
|
914
|
+
github_api_url: "https://api.github.com".to_string(),
|
|
383
915
|
channel: "default".to_string(),
|
|
384
916
|
skip_make: false,
|
|
385
917
|
force: false,
|
|
@@ -388,6 +920,13 @@ mod tests {
|
|
|
388
920
|
}
|
|
389
921
|
}
|
|
390
922
|
|
|
923
|
+
fn github_publish_args(root: PathBuf, dry_run: bool, api_url: &str) -> PublishArgs {
|
|
924
|
+
let mut args = publish_args(root, dry_run);
|
|
925
|
+
args.publisher = crate::cli::PublishTarget::Github;
|
|
926
|
+
args.github_api_url = api_url.to_string();
|
|
927
|
+
args
|
|
928
|
+
}
|
|
929
|
+
|
|
391
930
|
fn write_package_json(root: &Path) {
|
|
392
931
|
fs::write(
|
|
393
932
|
root.join("package.json"),
|
|
@@ -396,6 +935,14 @@ mod tests {
|
|
|
396
935
|
.expect("package.json should be written");
|
|
397
936
|
}
|
|
398
937
|
|
|
938
|
+
fn write_github_package_json(root: &Path) {
|
|
939
|
+
fs::write(
|
|
940
|
+
root.join("package.json"),
|
|
941
|
+
r#"{"name":"starter-app","version":"0.1.0","repository":{"type":"git","url":"git+https://github.com/Ikana/electron-cli.git"},"main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
|
|
942
|
+
)
|
|
943
|
+
.expect("package.json should be written");
|
|
944
|
+
}
|
|
945
|
+
|
|
399
946
|
fn write_app_file(root: &Path) {
|
|
400
947
|
fs::create_dir_all(root.join("src")).expect("src should be created");
|
|
401
948
|
fs::write(root.join("src/main.js"), "console.log('hello');")
|
|
@@ -429,4 +976,151 @@ mod tests {
|
|
|
429
976
|
fs::create_dir_all(&path).expect("temp dir should be created");
|
|
430
977
|
path
|
|
431
978
|
}
|
|
979
|
+
|
|
980
|
+
#[derive(Debug, Clone)]
|
|
981
|
+
struct RecordedRequest {
|
|
982
|
+
method: String,
|
|
983
|
+
path: String,
|
|
984
|
+
headers: Vec<(String, String)>,
|
|
985
|
+
body: Vec<u8>,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
impl RecordedRequest {
|
|
989
|
+
fn header(&self, name: &str) -> Option<String> {
|
|
990
|
+
self.headers
|
|
991
|
+
.iter()
|
|
992
|
+
.find(|(key, _)| key.eq_ignore_ascii_case(name))
|
|
993
|
+
.map(|(_, value)| value.clone())
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
struct MockGithubServer {
|
|
998
|
+
api_url: String,
|
|
999
|
+
requests: Arc<Mutex<Vec<RecordedRequest>>>,
|
|
1000
|
+
handle: Option<thread::JoinHandle<()>>,
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
impl MockGithubServer {
|
|
1004
|
+
fn new(request_count: usize) -> Self {
|
|
1005
|
+
let listener = TcpListener::bind("127.0.0.1:0").expect("server should bind");
|
|
1006
|
+
let address = listener.local_addr().expect("server address should read");
|
|
1007
|
+
let api_url = format!("http://{address}");
|
|
1008
|
+
let requests = Arc::new(Mutex::new(Vec::new()));
|
|
1009
|
+
let thread_requests = Arc::clone(&requests);
|
|
1010
|
+
let thread_api_url = api_url.clone();
|
|
1011
|
+
let handle = thread::spawn(move || {
|
|
1012
|
+
for index in 0..request_count {
|
|
1013
|
+
let (mut stream, _) = listener.accept().expect("request should connect");
|
|
1014
|
+
let request = read_http_request(&mut stream);
|
|
1015
|
+
thread_requests
|
|
1016
|
+
.lock()
|
|
1017
|
+
.expect("requests should lock")
|
|
1018
|
+
.push(request);
|
|
1019
|
+
|
|
1020
|
+
match index {
|
|
1021
|
+
0 => write_http_response(&mut stream, 404, r#"{"message":"Not Found"}"#),
|
|
1022
|
+
1 => write_http_response(
|
|
1023
|
+
&mut stream,
|
|
1024
|
+
201,
|
|
1025
|
+
&format!(
|
|
1026
|
+
r#"{{
|
|
1027
|
+
"html_url":"https://github.com/Ikana/electron-cli/releases/tag/v0.1.0",
|
|
1028
|
+
"upload_url":"{}/uploads/1{{?name,label}}",
|
|
1029
|
+
"assets":[]
|
|
1030
|
+
}}"#,
|
|
1031
|
+
thread_api_url
|
|
1032
|
+
),
|
|
1033
|
+
),
|
|
1034
|
+
_ => write_http_response(
|
|
1035
|
+
&mut stream,
|
|
1036
|
+
201,
|
|
1037
|
+
r#"{
|
|
1038
|
+
"id":42,
|
|
1039
|
+
"name":"starter-app",
|
|
1040
|
+
"browser_download_url":"https://github.com/Ikana/electron-cli/releases/download/v0.1.0/starter-app.zip"
|
|
1041
|
+
}"#,
|
|
1042
|
+
),
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
Self {
|
|
1048
|
+
api_url,
|
|
1049
|
+
requests,
|
|
1050
|
+
handle: Some(handle),
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
fn finish(mut self) -> Vec<RecordedRequest> {
|
|
1055
|
+
if let Some(handle) = self.handle.take() {
|
|
1056
|
+
handle.join().expect("server thread should finish");
|
|
1057
|
+
}
|
|
1058
|
+
self.requests.lock().expect("requests should lock").clone()
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
fn read_http_request(stream: &mut TcpStream) -> RecordedRequest {
|
|
1063
|
+
let mut bytes = Vec::new();
|
|
1064
|
+
let header_end = loop {
|
|
1065
|
+
let mut buffer = [0; 1024];
|
|
1066
|
+
let read = stream.read(&mut buffer).expect("request should read");
|
|
1067
|
+
assert!(read > 0, "connection closed before request headers");
|
|
1068
|
+
bytes.extend_from_slice(&buffer[..read]);
|
|
1069
|
+
if let Some(position) = bytes.windows(4).position(|window| window == b"\r\n\r\n") {
|
|
1070
|
+
break position + 4;
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
|
|
1074
|
+
let headers_text =
|
|
1075
|
+
String::from_utf8(bytes[..header_end].to_vec()).expect("headers should be utf-8");
|
|
1076
|
+
let mut lines = headers_text.split("\r\n");
|
|
1077
|
+
let request_line = lines.next().expect("request line should exist");
|
|
1078
|
+
let mut request_parts = request_line.split_whitespace();
|
|
1079
|
+
let method = request_parts
|
|
1080
|
+
.next()
|
|
1081
|
+
.expect("request method should exist")
|
|
1082
|
+
.to_string();
|
|
1083
|
+
let path = request_parts
|
|
1084
|
+
.next()
|
|
1085
|
+
.expect("request path should exist")
|
|
1086
|
+
.to_string();
|
|
1087
|
+
let headers = lines
|
|
1088
|
+
.filter_map(|line| line.split_once(':'))
|
|
1089
|
+
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
|
|
1090
|
+
.collect::<Vec<_>>();
|
|
1091
|
+
let content_length = headers
|
|
1092
|
+
.iter()
|
|
1093
|
+
.find(|(key, _)| key.eq_ignore_ascii_case("content-length"))
|
|
1094
|
+
.and_then(|(_, value)| value.parse::<usize>().ok())
|
|
1095
|
+
.unwrap_or(0);
|
|
1096
|
+
while bytes.len() < header_end + content_length {
|
|
1097
|
+
let mut buffer = [0; 1024];
|
|
1098
|
+
let read = stream.read(&mut buffer).expect("request body should read");
|
|
1099
|
+
assert!(read > 0, "connection closed before request body");
|
|
1100
|
+
bytes.extend_from_slice(&buffer[..read]);
|
|
1101
|
+
}
|
|
1102
|
+
let body = bytes[header_end..header_end + content_length].to_vec();
|
|
1103
|
+
|
|
1104
|
+
RecordedRequest {
|
|
1105
|
+
method,
|
|
1106
|
+
path,
|
|
1107
|
+
headers,
|
|
1108
|
+
body,
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
fn write_http_response(stream: &mut TcpStream, status: u16, body: &str) {
|
|
1113
|
+
let reason = match status {
|
|
1114
|
+
201 => "Created",
|
|
1115
|
+
404 => "Not Found",
|
|
1116
|
+
_ => "OK",
|
|
1117
|
+
};
|
|
1118
|
+
write!(
|
|
1119
|
+
stream,
|
|
1120
|
+
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
1121
|
+
body.len(),
|
|
1122
|
+
body
|
|
1123
|
+
)
|
|
1124
|
+
.expect("response should write");
|
|
1125
|
+
}
|
|
432
1126
|
}
|