diff --git a/app/components/crate-sidebar.hbs b/app/components/crate-sidebar.hbs
index e0da2d854fe..e857a2d34bf 100644
--- a/app/components/crate-sidebar.hbs
+++ b/app/components/crate-sidebar.hbs
@@ -119,4 +119,22 @@
{{/if}}
{{/unless}}
+
+ {{#if this.playgroundLink}}
+
+
+ Try on Rust Playground
+
+
+ The top 100 crates are available on the Rust Playground for you to
+ try out directly in your browser.
+
+
+ {{/if}}
\ No newline at end of file
diff --git a/app/components/crate-sidebar.js b/app/components/crate-sidebar.js
index 654f989eea2..0ddbc27c5ff 100644
--- a/app/components/crate-sidebar.js
+++ b/app/components/crate-sidebar.js
@@ -1,12 +1,18 @@
import { computed } from '@ember/object';
import { gt, readOnly } from '@ember/object/computed';
+import { inject as service } from '@ember/service';
import Component from '@glimmer/component';
+import * as Sentry from '@sentry/browser';
+import { didCancel } from 'ember-concurrency';
+
import { simplifyUrl } from './crate-sidebar/link';
const NUM_VERSIONS = 5;
export default class DownloadGraph extends Component {
+ @service playground;
+
@readOnly('args.crate.versions') sortedVersions;
@computed('sortedVersions')
@@ -24,4 +30,28 @@ export default class DownloadGraph extends Component {
get tomlSnippet() {
return `${this.args.crate.name} = "${this.args.version.num}"`;
}
+
+ get playgroundLink() {
+ let playgroundCrates = this.playground.crates;
+ if (!playgroundCrates) return;
+
+ let playgroundCrate = playgroundCrates.find(it => it.name === this.args.crate.name);
+ if (!playgroundCrate) return;
+
+ return `https://play.rust-lang.org/?code=use%20${playgroundCrate.id}%3B%0A%0Afn%20main()%20%7B%0A%20%20%20%20%2F%2F%20try%20using%20the%20%60${playgroundCrate.id}%60%20crate%20here%0A%7D`;
+ }
+
+ constructor() {
+ super(...arguments);
+
+ // load Rust Playground crates list, if necessary
+ if (!this.playground.crates) {
+ this.playground.loadCratesTask.perform().catch(error => {
+ if (!(didCancel(error) || error.isServerError || error.isNetworkError)) {
+ // report unexpected errors to Sentry
+ Sentry.captureException(error);
+ }
+ });
+ }
+ }
}
diff --git a/app/components/crate-sidebar.module.css b/app/components/crate-sidebar.module.css
index 456c0a92c91..45c65099406 100644
--- a/app/components/crate-sidebar.module.css
+++ b/app/components/crate-sidebar.module.css
@@ -119,3 +119,17 @@ ul.owners {
.reverse-deps-link {
composes: small from '../styles/shared/typography.module.css';
}
+
+.playground-button {
+ composes: yellow-button small from '../styles/shared/buttons.module.css';
+ justify-content: center;
+ width: 220px;
+ margin-top: 20px;
+}
+
+.playground-help {
+ composes: small from '../styles/shared/typography.module.css';
+ max-width: 220px;
+ text-align: justify;
+ line-height: 1.3em;
+}
diff --git a/app/routes/application.js b/app/routes/application.js
index ceb6e39addc..111260609d9 100644
--- a/app/routes/application.js
+++ b/app/routes/application.js
@@ -3,11 +3,13 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import * as Sentry from '@sentry/browser';
+import { rawTimeout, task } from 'ember-concurrency';
export default class ApplicationRoute extends Route {
@service progress;
@service router;
@service session;
+ @service playground;
beforeModel() {
this.router.on('routeDidChange', () => {
@@ -24,10 +26,21 @@ export default class ApplicationRoute extends Route {
//
// eslint-disable-next-line ember-concurrency/no-perform-without-catch
this.session.loadUserTask.perform();
+
+ // trigger the preload task, but don't wait for the task to finish.
+ this.preloadPlaygroundCratesTask.perform().catch(() => {
+ // ignore all errors since we're only preloading here
+ });
}
@action loading(transition) {
this.progress.handle(transition);
return true;
}
+
+ @task(function* () {
+ yield rawTimeout(1000);
+ yield this.playground.loadCratesTask.perform();
+ })
+ preloadPlaygroundCratesTask;
}
diff --git a/app/services/playground.js b/app/services/playground.js
new file mode 100644
index 00000000000..5d8ee3b55e4
--- /dev/null
+++ b/app/services/playground.js
@@ -0,0 +1,16 @@
+import { alias } from '@ember/object/computed';
+import Service from '@ember/service';
+
+import { task } from 'ember-concurrency';
+
+import ajax from '../utils/ajax';
+
+export default class PlaygroundService extends Service {
+ @alias('loadCratesTask.lastSuccessful.value') crates;
+
+ @(task(function* () {
+ let response = yield ajax('https://play.rust-lang.org/meta/crates');
+ return response.crates;
+ }).drop())
+ loadCratesTask;
+}
diff --git a/app/utils/ajax.js b/app/utils/ajax.js
index 106b3949a6a..eb57d1f3d25 100644
--- a/app/utils/ajax.js
+++ b/app/utils/ajax.js
@@ -46,6 +46,26 @@ export class AjaxError extends Error {
this.cause = cause;
}
+ get isJsonError() {
+ return this.cause instanceof SyntaxError;
+ }
+
+ get isNetworkError() {
+ return this.cause instanceof TypeError;
+ }
+
+ get isHttpError() {
+ return this.cause instanceof HttpError;
+ }
+
+ get isServerError() {
+ return this.isHttpError && this.cause.response.status >= 500 && this.cause.response.status < 600;
+ }
+
+ get isClientError() {
+ return this.isHttpError && this.cause.response.status >= 400 && this.cause.response.status < 500;
+ }
+
async json() {
try {
return await this.cause.response.json();
diff --git a/config/nginx.conf.erb b/config/nginx.conf.erb
index 8e96bb040c8..765713049c6 100644
--- a/config/nginx.conf.erb
+++ b/config/nginx.conf.erb
@@ -211,7 +211,7 @@ http {
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' *.ingest.sentry.io https://docs.rs https://<%= s3_host(ENV) %>; script-src 'self' 'unsafe-eval' 'sha256-n1+BB7Ckjcal1Pr7QNBh/dKRTtBQsIytFodRiIosXdE='; style-src 'self' 'unsafe-inline' https://code.cdn.mozilla.net; font-src https://code.cdn.mozilla.net; img-src *; object-src 'none'";
+ add_header Content-Security-Policy "default-src 'self'; connect-src 'self' *.ingest.sentry.io https://docs.rs https://play.rust-lang.org https://<%= s3_host(ENV) %>; script-src 'self' 'unsafe-eval' 'sha256-n1+BB7Ckjcal1Pr7QNBh/dKRTtBQsIytFodRiIosXdE='; style-src 'self' 'unsafe-inline' https://code.cdn.mozilla.net; font-src https://code.cdn.mozilla.net; img-src *; object-src 'none'";
add_header Access-Control-Allow-Origin "*";
add_header Strict-Transport-Security "max-age=31536000" always;
diff --git a/tests/components/crate-sidebar/playground-button-test.js b/tests/components/crate-sidebar/playground-button-test.js
new file mode 100644
index 00000000000..f470292959a
--- /dev/null
+++ b/tests/components/crate-sidebar/playground-button-test.js
@@ -0,0 +1,93 @@
+import { render, settled, waitFor } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import { defer } from 'rsvp';
+
+import { hbs } from 'ember-cli-htmlbars';
+
+import { setupRenderingTest } from 'cargo/tests/helpers';
+
+import setupMirage from '../../helpers/setup-mirage';
+
+module('Component | CrateSidebar | Playground Button', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ let crates = [
+ { name: 'addr2line', version: '0.14.1', id: 'addr2line' },
+ { name: 'adler', version: '0.2.3', id: 'adler' },
+ { name: 'adler32', version: '1.2.0', id: 'adler32' },
+ { name: 'ahash', version: '0.4.7', id: 'ahash' },
+ { name: 'aho-corasick', version: '0.7.15', id: 'aho_corasick' },
+ { name: 'ansi_term', version: '0.12.1', id: 'ansi_term' },
+ { name: 'ansi_term', version: '0.11.0', id: 'ansi_term_0_11_0' },
+ ];
+
+ this.server.get('https://play.rust-lang.org/meta/crates', { crates });
+ });
+
+ test('button is hidden for unavailable crates', async function (assert) {
+ let crate = this.server.create('crate', { name: 'foo' });
+ this.server.create('version', { crate, num: '1.0.0' });
+
+ let store = this.owner.lookup('service:store');
+ this.crate = await store.findRecord('crate', crate.name);
+ this.version = (await this.crate.versions).firstObject;
+
+ await render(hbs``);
+ assert.dom('[data-test-playground-button]').doesNotExist();
+ assert.dom('[data-test-playground-help]').doesNotExist();
+ });
+
+ test('button is visible for available crates', async function (assert) {
+ let crate = this.server.create('crate', { name: 'aho-corasick' });
+ this.server.create('version', { crate, num: '1.0.0' });
+
+ let store = this.owner.lookup('service:store');
+ this.crate = await store.findRecord('crate', crate.name);
+ this.version = (await this.crate.versions).firstObject;
+
+ let expectedHref =
+ 'https://play.rust-lang.org/?code=use%20aho_corasick%3B%0A%0Afn%20main()%20%7B%0A%20%20%20%20%2F%2F%20try%20using%20the%20%60aho_corasick%60%20crate%20here%0A%7D';
+
+ await render(hbs``);
+ assert.dom('[data-test-playground-button]').hasAttribute('href', expectedHref);
+ assert.dom('[data-test-playground-help]').exists();
+ });
+
+ test('button is hidden while Playground request is pending', async function (assert) {
+ let crate = this.server.create('crate', { name: 'aho-corasick' });
+ this.server.create('version', { crate, num: '1.0.0' });
+
+ let deferred = defer();
+ this.server.get('https://play.rust-lang.org/meta/crates', deferred.promise);
+
+ let store = this.owner.lookup('service:store');
+ this.crate = await store.findRecord('crate', crate.name);
+ this.version = (await this.crate.versions).firstObject;
+
+ render(hbs``);
+ await waitFor('[data-test-owners]');
+ assert.dom('[data-test-playground-button]').doesNotExist();
+ assert.dom('[data-test-playground-help]').doesNotExist();
+
+ deferred.resolve({ crates: [] });
+ await settled();
+ });
+
+ test('button is hidden if the Playground request fails', async function (assert) {
+ let crate = this.server.create('crate', { name: 'aho-corasick' });
+ this.server.create('version', { crate, num: '1.0.0' });
+
+ this.server.get('https://play.rust-lang.org/meta/crates', {}, 500);
+
+ let store = this.owner.lookup('service:store');
+ this.crate = await store.findRecord('crate', crate.name);
+ this.version = (await this.crate.versions).firstObject;
+
+ await render(hbs``);
+ assert.dom('[data-test-playground-button]').doesNotExist();
+ assert.dom('[data-test-playground-help]').doesNotExist();
+ });
+});
diff --git a/tests/services/playground-test.js b/tests/services/playground-test.js
new file mode 100644
index 00000000000..ca063956950
--- /dev/null
+++ b/tests/services/playground-test.js
@@ -0,0 +1,38 @@
+import { module, test } from 'qunit';
+
+import { setupMirage } from 'ember-cli-mirage/test-support';
+
+import { setupTest } from 'cargo/tests/helpers';
+
+module('Service | Playground', function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ this.playground = this.owner.lookup('service:playground');
+ });
+
+ test('`crates` are available if the request succeeds', async function (assert) {
+ let crates = [
+ { name: 'addr2line', version: '0.14.1', id: 'addr2line' },
+ { name: 'adler', version: '0.2.3', id: 'adler' },
+ { name: 'adler32', version: '1.2.0', id: 'adler32' },
+ { name: 'ahash', version: '0.4.7', id: 'ahash' },
+ { name: 'aho-corasick', version: '0.7.15', id: 'aho_corasick' },
+ { name: 'ansi_term', version: '0.12.1', id: 'ansi_term' },
+ { name: 'ansi_term', version: '0.11.0', id: 'ansi_term_0_11_0' },
+ ];
+
+ this.server.get('https://play.rust-lang.org/meta/crates', { crates }, 200);
+
+ await this.playground.loadCratesTask.perform();
+ assert.deepEqual(this.playground.crates, crates);
+ });
+
+ test('loadCratesTask fails on HTTP error', async function (assert) {
+ this.server.get('https://play.rust-lang.org/meta/crates', {}, 500);
+
+ await assert.rejects(this.playground.loadCratesTask.perform());
+ assert.strictEqual(this.playground.crates, undefined);
+ });
+});
diff --git a/tests/utils/ajax-test.js b/tests/utils/ajax-test.js
index d2a0e2c9905..fb5b9b9ec88 100644
--- a/tests/utils/ajax-test.js
+++ b/tests/utils/ajax-test.js
@@ -25,7 +25,7 @@ module('ajax()', function (hooks) {
assert.deepEqual(response, { foo: 'bar' });
});
- test('throws an `HttpError` for non-2xx responses', async function (assert) {
+ test('throws an `HttpError` for 5xx responses', async function (assert) {
this.server.get('/foo', { foo: 42 }, 500);
await assert.rejects(ajax('/foo'), function (error) {
@@ -37,6 +37,11 @@ module('ajax()', function (hooks) {
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
+ assert.true(error.isHttpError);
+ assert.true(error.isServerError);
+ assert.false(error.isClientError);
+ assert.false(error.isJsonError);
+ assert.false(error.isNetworkError);
let { cause } = error;
assert.ok(cause instanceof HttpError);
@@ -50,6 +55,36 @@ module('ajax()', function (hooks) {
});
});
+ test('throws an `HttpError` for 4xx responses', async function (assert) {
+ this.server.get('/foo', { foo: 42 }, 404);
+
+ await assert.rejects(ajax('/foo'), function (error) {
+ let expectedMessage = 'GET /foo failed\n\ncaused by: HttpError: GET /foo failed with: 404 Not Found';
+
+ assert.ok(error instanceof AjaxError);
+ assert.equal(error.name, 'AjaxError');
+ assert.ok(error.message.startsWith(expectedMessage), error.message);
+ assert.equal(error.method, 'GET');
+ assert.equal(error.url, '/foo');
+ assert.ok(error.cause);
+ assert.true(error.isHttpError);
+ assert.false(error.isServerError);
+ assert.true(error.isClientError);
+ assert.false(error.isJsonError);
+ assert.false(error.isNetworkError);
+
+ let { cause } = error;
+ assert.ok(cause instanceof HttpError);
+ assert.equal(cause.name, 'HttpError');
+ assert.equal(cause.message, 'GET /foo failed with: 404 Not Found');
+ assert.equal(cause.method, 'GET');
+ assert.equal(cause.url, '/foo');
+ assert.ok(cause.response);
+ assert.equal(cause.response.url, '/foo');
+ return true;
+ });
+ });
+
test('throws an error for invalid JSON responses', async function (assert) {
this.server.get('/foo', () => '{ foo: 42');
@@ -62,6 +97,11 @@ module('ajax()', function (hooks) {
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
+ assert.false(error.isHttpError);
+ assert.false(error.isServerError);
+ assert.false(error.isClientError);
+ assert.true(error.isJsonError);
+ assert.false(error.isNetworkError);
let { cause } = error;
assert.ok(!(cause instanceof HttpError));
@@ -85,6 +125,11 @@ module('ajax()', function (hooks) {
assert.equal(error.method, 'GET');
assert.equal(error.url, '/foo');
assert.ok(error.cause);
+ assert.false(error.isHttpError);
+ assert.false(error.isServerError);
+ assert.false(error.isClientError);
+ assert.false(error.isJsonError);
+ assert.true(error.isNetworkError);
let { cause } = error;
assert.ok(!(cause instanceof HttpError));