diff --git a/.github/workflows/daily_planner.yml b/.github/workflows/daily_planner.yml index 3c0c06622d..a4308d560f 100644 --- a/.github/workflows/daily_planner.yml +++ b/.github/workflows/daily_planner.yml @@ -7,6 +7,7 @@ on: pull_request: paths: - motoko/daily_planner/** + - rust/daily_planner/** - .github/workflows/daily_planner.yml concurrency: @@ -27,3 +28,17 @@ jobs: icp network start -d icp deploy make test + + rust-daily_planner: + runs-on: ubuntu-24.04 + container: ghcr.io/dfinity/icp-dev-env-rust:1.0.0 + env: + ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Deploy and test + working-directory: rust/daily_planner + run: | + icp network start -d + icp deploy + make test diff --git a/rust/daily_planner/.devcontainer/devcontainer.json b/rust/daily_planner/.devcontainer/devcontainer.json deleted file mode 100644 index ebb0b8bcc6..0000000000 --- a/rust/daily_planner/.devcontainer/devcontainer.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "ICP Dev Environment", - "image": "ghcr.io/dfinity/icp-dev-env-slim:22", - "forwardPorts": [4943, 5173], - "portsAttributes": { - "4943": { - "label": "dfx", - "onAutoForward": "ignore" - }, - "5173": { - "label": "vite", - "onAutoForward": "openBrowser" - } - }, - "customizations": { - "vscode": { - "extensions": ["dfinity-foundation.vscode-motoko"] - } - } -} diff --git a/rust/daily_planner/BUILD.md b/rust/daily_planner/BUILD.md deleted file mode 100644 index 24cfcb7547..0000000000 --- a/rust/daily_planner/BUILD.md +++ /dev/null @@ -1,113 +0,0 @@ -# Continue building locally - -Projects deployed through ICP Ninja are temporary; they will only be live for 20 minutes before they are removed. The command-line tool `dfx` can be used to continue building your ICP Ninja project locally and deploy it to the mainnet. - -To migrate your ICP Ninja project off of the web browser and develop it locally, follow these steps. - -### 1. Install developer tools. - -You can install the developer tools natively or use Dev Containers. - -#### Option 1: Natively install developer tools - -> Installing `dfx` natively is currently only supported on macOS and Linux systems. On Windows, it is recommended to use the Dev Containers option. - -1. Install `dfx` with the following command: - -``` - -sh -ci "$(curl -fsSL https://internetcomputer.org/install.sh)" - -``` - -> On Apple Silicon (e.g., Apple M1 chip), make sure you have Rosetta installed (`softwareupdate --install-rosetta`). - -2. [Install NodeJS](https://nodejs.org/en/download/package-manager). - -3. For Rust projects, you will also need to: - -- Install [Rust](https://doc.rust-lang.org/cargo/getting-started/installation.html#install-rust-and-cargo): `curl https://sh.rustup.rs -sSf | sh` - -- Install [candid-extractor](https://crates.io/crates/candid-extractor): `cargo install candid-extractor` - -4. For Motoko projects, you will also need to: - -- Install the Motoko package manager [Mops](https://docs.mops.one/quick-start#2-install-mops-cli): `npm i -g ic-mops` - -Lastly, navigate into your project's directory that you downloaded from ICP Ninja. - -#### Option 2: Dev Containers - -Continue building your projects locally by installing the [Dev Container extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) for VS Code and [Docker](https://docs.docker.com/engine/install/). - -Make sure Docker is running, then navigate into your project's directory that you downloaded from ICP Ninja and start the Dev Container by selecting `Dev-Containers: Reopen in Container` in VS Code's command palette (F1 or Ctrl/Cmd+Shift+P). - -> Note that local development ports (e.g. the ports used by `dfx` or `vite`) are forwarded from the Dev Container to your local machine. In the VS code terminal, use Ctrl/Cmd+Click on the displayed local URLs to open them in your browser. To view the current port mappings, click the "Ports" tab in the VS Code terminal window. - -### 2. Start the local development environment. - -``` -dfx start --background -``` - -### 3. Create a local developer identity. - -To manage your project's canisters, it is recommended that you create a local [developer identity](https://internetcomputer.org/docs/building-apps/getting-started/identities) rather than use the `dfx` default identity that is not stored securely. - -To create a new identity, run the commands: - -``` - -dfx identity new IDENTITY_NAME - -dfx identity use IDENTITY_NAME - -``` - -Replace `IDENTITY_NAME` with your preferred identity name. The first command `dfx start --background` starts the local `dfx` processes, then `dfx identity new` will create a new identity and return your identity's seed phase. Be sure to save this in a safe, secure location. - -The third command `dfx identity use` will tell `dfx` to use your new identity as the active identity. Any canister smart contracts created after running `dfx identity use` will be owned and controlled by the active identity. - -Your identity will have a principal ID associated with it. Principal IDs are used to identify different entities on ICP, such as users and canisters. - -[Learn more about ICP developer identities](https://internetcomputer.org/docs/building-apps/getting-started/identities). - -### 4. Deploy the project locally. - -Deploy your project to your local developer environment with: - -``` -npm install -dfx deploy - -``` - -Your project will be hosted on your local machine. The local canister URLs for your project will be shown in the terminal window as output of the `dfx deploy` command. You can open these URLs in your web browser to view the local instance of your project. - -### 5. Obtain cycles. - -To deploy your project to the mainnet for long-term public accessibility, first you will need [cycles](https://internetcomputer.org/docs/building-apps/getting-started/tokens-and-cycles). Cycles are used to pay for the resources your project uses on the mainnet, such as storage and compute. - -> This cost model is known as ICP's [reverse gas model](https://internetcomputer.org/docs/building-apps/essentials/gas-cost), where developers pay for their project's gas fees rather than users pay for their own gas fees. This model provides an enhanced end user experience since they do not need to hold tokens or sign transactions when using a dapp deployed on ICP. - -> Learn how much a project may cost by using the [pricing calculator](https://internetcomputer.org/docs/building-apps/essentials/cost-estimations-and-examples). - -Cycles can be obtained through [converting ICP tokens into cycles using `dfx`](https://internetcomputer.org/docs/building-apps/developer-tools/dfx/dfx-cycles#dfx-cycles-convert). - -### 6. Deploy to the mainnet. - -Once you have cycles, run the command: - -``` - -dfx deploy --network ic - -``` - -After your project has been deployed to the mainnet, it will continuously require cycles to pay for the resources it uses. You will need to [top up](https://internetcomputer.org/docs/building-apps/canister-management/topping-up) your project's canisters or set up automatic cycles management through a service such as [CycleOps](https://cycleops.dev/). - -> If your project's canisters run out of cycles, they will be removed from the network. - -## Additional examples - -Additional code examples and sample applications can be found in the [DFINITY examples repo](https://github.com/dfinity/examples). diff --git a/rust/daily_planner/Makefile b/rust/daily_planner/Makefile new file mode 100644 index 0000000000..fe3a20c0cc --- /dev/null +++ b/rust/daily_planner/Makefile @@ -0,0 +1,39 @@ +.PHONY: test + +test: + @echo "=== Test 1/6: get_day_data returns null for a date with no notes ===" + @result=$$(icp canister call --query backend get_day_data '("2000-01-15")') && \ + echo "$$result" && \ + echo "$$result" | grep -q 'null' && \ + echo "PASS" || (echo "FAIL" && exit 1) + + @echo "=== Test 2/6: add_note returns ok result ===" + @result=$$(icp canister call backend add_note '("2000-01-15", "Buy groceries")') && \ + echo "$$result" && \ + echo "$$result" | grep -q 'ok' && \ + echo "PASS" || (echo "FAIL" && exit 1) + + @echo "=== Test 3/6: get_day_data returns stored note ===" + @result=$$(icp canister call --query backend get_day_data '("2000-01-15")') && \ + echo "$$result" && \ + echo "$$result" | grep -q 'Buy groceries' && \ + echo "PASS" || (echo "FAIL" && exit 1) + + @echo "=== Test 4/6: get_month_data returns entry for the stored month ===" + @result=$$(icp canister call --query backend get_month_data '(2000 : nat, 1 : nat)') && \ + echo "$$result" && \ + echo "$$result" | grep -q 'Buy groceries' && \ + echo "PASS" || (echo "FAIL" && exit 1) + + @echo "=== Test 5/6: complete_note marks note as completed ===" + @icp canister call backend complete_note '("2000-01-15", 0 : nat)' && \ + result=$$(icp canister call --query backend get_day_data '("2000-01-15")') && \ + echo "$$result" && \ + echo "$$result" | grep -q 'is_completed = true' && \ + echo "PASS" || (echo "FAIL" && exit 1) + + @echo "=== Test 6/6: get_month_data returns empty list for a different month ===" + @result=$$(icp canister call --query backend get_month_data '(1999 : nat, 12 : nat)') && \ + echo "$$result" && \ + echo "$$result" | grep -q 'vec {}' && \ + echo "PASS" || (echo "FAIL" && exit 1) diff --git a/rust/daily_planner/README.md b/rust/daily_planner/README.md index fa2721cea8..0974391678 100644 --- a/rust/daily_planner/README.md +++ b/rust/daily_planner/README.md @@ -1,27 +1,42 @@ -# Daily planner +# Daily Planner -Daily planner features a monthly calender that can be used to track daily activities, appointments, or tasks. Data for each task is stored onchain. For each day, a historic fact can be queried using HTTPS outcalls, which is a feature that allows ICP canisters to obtain data from external sources. +Daily Planner is a full-stack ICP example featuring a monthly calendar that tracks daily notes and tasks stored on the network. For each day, a historic fact can be fetched from an external API using HTTPS outcalls, demonstrating how ICP canisters can access data from external services. -## Deploying from ICP Ninja +## Build and deploy from the command line -When viewing this project in ICP Ninja, you can deploy it directly to the mainnet for free by clicking "Run" in the upper right corner. Open this project in ICP Ninja: +### Prerequisites -[![](https://icp.ninja/assets/open.svg)](https://icp.ninja/i?g=https://github.com/dfinity/examples/rust/daily_planner) +- Node.js +- icp-cli: `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm` -## Build and deploy from the command-line +### Install -### 1. [Download and install the IC SDK.](https://internetcomputer.org/docs/building-apps/getting-started/install) +```bash +git clone https://github.com/dfinity/examples +cd examples/rust/daily_planner +``` -### 2. Download your project from ICP Ninja using the 'Download files' button on the upper left corner, or [clone the GitHub examples repository.](https://github.com/dfinity/examples/) +### Deploy and test -### 3. Navigate into the project's directory. +```bash +icp network start -d +icp deploy +make test +icp network stop +``` -### 4. Deploy the project to your local environment: +To run the frontend in development mode with hot reload: +```bash +npm run dev ``` -dfx start --background --clean && dfx deploy + +## Updating the Candid interface + +```bash +icp build backend && candid-extractor target/wasm32-unknown-unknown/release/backend.wasm > backend/backend.did ``` ## Security considerations and best practices -If you base your application on this example, it is recommended that you familiarize yourself with and adhere to the [security best practices](https://internetcomputer.org/docs/building-apps/security/overview) for developing on ICP. This example may not implement all the best practices. +Refer to the [security best practices](https://docs.internetcomputer.org/guides/security/overview) for information on security and best practices for your ICP app. diff --git a/rust/daily_planner/backend/Cargo.toml b/rust/daily_planner/backend/Cargo.toml index 6c747e7902..a6c3e70955 100644 --- a/rust/daily_planner/backend/Cargo.toml +++ b/rust/daily_planner/backend/Cargo.toml @@ -3,15 +3,13 @@ name = "backend" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [lib] crate-type = ["cdylib"] path = "lib.rs" [dependencies] -candid = "0.10.10" -ic-cdk = "0.16.0" +candid = "0.10" +ic-cdk = "0.20" ic-stable-structures = "0.6.5" -serde = "1.0.210" -serde_json = "1.0.138" +serde = "1.0" +serde_json = "1.0" diff --git a/rust/daily_planner/backend/backend.did b/rust/daily_planner/backend/backend.did new file mode 100644 index 0000000000..3d690ffd25 --- /dev/null +++ b/rust/daily_planner/backend/backend.did @@ -0,0 +1,24 @@ +type DayDataEntry = record { + notes : vec Note; + on_this_day : opt OnThisDay; +}; + +type Note = record { + id : nat; + content : text; + is_completed : bool; +}; + +type OnThisDay = record { + title : text; + year : text; + wiki_link : text; +}; + +service : { + get_day_data : (text) -> (opt DayDataEntry) query; + get_month_data : (nat, nat) -> (vec record { text; DayDataEntry }) query; + add_note : (text, text) -> (variant { ok : text; err : text }); + complete_note : (text, nat) -> (); + fetch_and_store_on_this_day : (text) -> (variant { ok : text; err : text }); +}; diff --git a/rust/daily_planner/backend/lib.rs b/rust/daily_planner/backend/lib.rs index 5b3f9a1463..3bc43041e9 100644 --- a/rust/daily_planner/backend/lib.rs +++ b/rust/daily_planner/backend/lib.rs @@ -1,7 +1,10 @@ use candid::{CandidType, Decode, Deserialize, Encode, Nat}; -use ic_cdk::api::management_canister::http_request::{ - http_request, CanisterHttpRequestArgument, HttpMethod, HttpResponse, TransformArgs, - TransformContext, +use ic_cdk::{ + api::canister_self, + management_canister::{ + http_request, HttpMethod, HttpRequestArgs, HttpRequestResult, TransformArgs, + TransformContext, TransformFunc, + }, }; use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; use ic_stable_structures::storable::{Bound, Storable}; @@ -99,7 +102,7 @@ fn add_note(date: String, content: String) -> Result { }; day_data.notes.push(new_note); state.day_data_entries.insert(date.clone(), day_data); - Ok(format!("Added not for date: {date}")) + Ok(format!("Added note for date: {date}")) // Currently returns no errors, but could be extended to e.g. reject creation of notes in the past. }) } @@ -154,21 +157,26 @@ async fn fetch_and_store_on_this_day(date: String) -> Result { // This is useful to e.g. filter out timestamps/sessionIDs out of headers that will be different across the responses the different replicas receive. // If the data (including status, headers and body) they receive does not match across the nodes, the canister will reject the response! // You can read more about it here: https://internetcomputer.org/docs/current/developer-docs/smart-contracts/advanced-features/https-outcalls/https-outcalls-how-to-use. - let transform_context = TransformContext::from_name("transform".to_string(), vec![]); - let request = CanisterHttpRequestArgument { + let request = HttpRequestArgs { url, method: HttpMethod::GET, body: None, max_response_bytes: None, // Can be set to limit cost. Our response has no predictable size, so we set no limit. headers: vec![], - transform: Some(transform_context), + transform: Some(TransformContext { + function: TransformFunc::new(canister_self(), "transform".to_string()), + context: vec![], + }), + // Replicated mode: all subnet nodes make the request independently, + // providing strong integrity guarantees via consensus. + is_replicated: Some(true), }; - // Perform HTTPS outcall using roughly 100B cycles. - // See https outcall cost calculator: https://7joko-hiaaa-aaaal-ajz7a-cai.icp0.io. + // Perform HTTPS outcall. Cycles are automatically calculated and attached. // Unused cycles are returned. - let quote = match http_request(request, 100_000_000_000).await { - Ok((response,)) => { + // See https outcall cost calculator: https://7joko-hiaaa-aaaal-ajz7a-cai.icp0.io. + let quote = match http_request(&request).await { + Ok(response) => { let body_string = String::from_utf8(response.body).expect("Response is not UTF-8 encoded."); let Some(otd) = http_response_to_on_this_day(&body_string) else { @@ -194,17 +202,16 @@ async fn fetch_and_store_on_this_day(date: String) -> Result { } // Query function to turn the raw HTTP responses into responses that nodes can run consensus on. -#[ic_cdk::query] -fn transform(raw: TransformArgs) -> HttpResponse { - HttpResponse { - status: raw.response.status, - body: raw.response.body, - headers: vec![], // We filter out the headers, as they don't match accross nodes. +#[ic_cdk::query(hidden = true)] +fn transform(raw: TransformArgs) -> HttpRequestResult { + HttpRequestResult { + headers: vec![], // We filter out the headers, as they don't match across nodes. + ..raw.response } } fn http_response_to_on_this_day(http: &str) -> Option { - let json: serde_json::Value = serde_json::from_str(&http).ok()?; + let json: serde_json::Value = serde_json::from_str(http).ok()?; let title = json["events"][0]["description"].as_str()?; let year = json["events"][0]["year"].as_str()?; let wiki_link = json["events"][0]["wikipedia"][0]["wikipedia"].as_str()?; diff --git a/rust/daily_planner/dfx.json b/rust/daily_planner/dfx.json deleted file mode 100644 index 6ed5a89e54..0000000000 --- a/rust/daily_planner/dfx.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "canisters": { - "backend": { - "candid": "backend/backend.did", - "type": "custom", - "shrink": true, - "gzip": true, - "wasm": "target/wasm32-unknown-unknown/release/backend.wasm", - "build": [ - "cargo build --target wasm32-unknown-unknown --release -p backend", - "candid-extractor target/wasm32-unknown-unknown/release/backend.wasm > backend/backend.did" - ], - "metadata": [ - { - "name": "candid:service" - } - ] - }, - "frontend": { - "dependencies": ["backend"], - "frontend": { - "entrypoint": "frontend/index.html" - }, - "source": ["frontend/dist"], - "type": "assets" - } - }, - "output_env_file": ".env" -} diff --git a/rust/daily_planner/frontend/package.json b/rust/daily_planner/frontend/package.json index e618cdc5a0..859836e9b9 100644 --- a/rust/daily_planner/frontend/package.json +++ b/rust/daily_planner/frontend/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "prebuild": "npm i --include=dev && dfx generate backend", + "prebuild": "npm i --include=dev", "build": "vite build", "dev": "vite" }, @@ -13,10 +13,10 @@ "react-dom": "18.3.1" }, "devDependencies": { + "@icp-sdk/bindgen": "~0.2.2", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.3", - "vite": "5.4.11", - "vite-plugin-environment": "1.1.3" + "vite": "5.4.11" } } diff --git a/rust/daily_planner/frontend/src/App.jsx b/rust/daily_planner/frontend/src/App.jsx index f8ad6fcfd6..928b0c3b0a 100644 --- a/rust/daily_planner/frontend/src/App.jsx +++ b/rust/daily_planner/frontend/src/App.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import '../index.css'; -import { backend } from 'declarations/backend'; +import { backend } from './actor'; const App = () => { const [currentDate, setCurrentDate] = useState(new Date()); diff --git a/rust/daily_planner/frontend/src/actor.js b/rust/daily_planner/frontend/src/actor.js new file mode 100644 index 0000000000..9667a63427 --- /dev/null +++ b/rust/daily_planner/frontend/src/actor.js @@ -0,0 +1,23 @@ +import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; +import { createActor } from "./bindings/backend"; + +// The ic_env cookie is set by the asset canister (SDK >=0.30.2) on all HTML +// responses. It contains the replica root key and any PUBLIC_* canister +// environment variables. In dev mode the vite dev server sets the same cookie +// via Set-Cookie header (see vite.config.js). +const canisterEnv = safeGetCanisterEnv(); + +const canisterId = canisterEnv?.["PUBLIC_CANISTER_ID:backend"]; + +if (!canisterId) { + throw new Error( + "Canister ID for 'backend' not found. Run 'icp deploy' first." + ); +} + +export const backend = createActor(canisterId, { + agentOptions: { + host: window.location.origin, + rootKey: canisterEnv?.IC_ROOT_KEY, + }, +}); diff --git a/rust/daily_planner/frontend/vite.config.js b/rust/daily_planner/frontend/vite.config.js index f9e04a9a93..1b9599b191 100644 --- a/rust/daily_planner/frontend/vite.config.js +++ b/rust/daily_planner/frontend/vite.config.js @@ -1,37 +1,50 @@ -import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; -import { fileURLToPath, URL } from 'url'; -import environment from 'vite-plugin-environment'; +import { defineConfig } from "vite"; +import { execSync } from "child_process"; +import react from "@vitejs/plugin-react"; +import { icpBindgen } from "@icp-sdk/bindgen/plugins/vite"; -export default defineConfig({ - base: './', - plugins: [react(), environment('all', { prefix: 'CANISTER_' }), environment('all', { prefix: 'DFX_' })], - envDir: '../', - define: { - 'process.env': process.env - }, - optimizeDeps: { - esbuildOptions: { - define: { - global: 'globalThis' - } - } - }, - resolve: { - alias: [ - { - find: 'declarations', - replacement: fileURLToPath(new URL('../src/declarations', import.meta.url)) - } - ] - }, - server: { - proxy: { - '/api': { - target: 'http://127.0.0.1:4943', - changeOrigin: true - } +function getDevServerConfig() { + try { + const canisterId = execSync("icp canister status backend -e local -i", { + encoding: "utf-8", + stdio: "pipe", + }).trim(); + const networkStatus = JSON.parse( + execSync("icp network status --json", { + encoding: "utf-8", + stdio: "pipe", + }) + ); + return { + headers: { + "Set-Cookie": `ic_env=${encodeURIComponent( + `ic_root_key=${networkStatus.root_key}&PUBLIC_CANISTER_ID:backend=${canisterId}` + )}; SameSite=Lax;`, + }, + proxy: { + "/api": { target: "http://127.0.0.1:8000", changeOrigin: true }, + }, + }; + } catch {} + + throw new Error( + "No local network running. Start with:\n icp network start -d && icp deploy" + ); +} + +export default defineConfig(({ command }) => { + return { + base: "./", + plugins: [ + react(), + icpBindgen({ + didFile: "../backend/backend.did", + outDir: "./src/bindings", + }), + ], + optimizeDeps: { + esbuildOptions: { define: { global: "globalThis" } }, }, - host: '127.0.0.1' - } + server: command === "serve" ? getDevServerConfig() : undefined, + }; }); diff --git a/rust/daily_planner/icp.yaml b/rust/daily_planner/icp.yaml new file mode 100644 index 0000000000..c329eab309 --- /dev/null +++ b/rust/daily_planner/icp.yaml @@ -0,0 +1,19 @@ +networks: + - name: local + mode: managed + +canisters: + - name: backend + recipe: + type: "@dfinity/rust@v3.3.0" + configuration: + candid: backend/backend.did + + - name: frontend + recipe: + type: "@dfinity/asset-canister@v2.2.1" + configuration: + dir: frontend/dist + build: + - npm install --prefix frontend + - npm run build --prefix frontend diff --git a/rust/daily_planner/package.json b/rust/daily_planner/package.json index 215cf98e03..199fc1ae9f 100644 --- a/rust/daily_planner/package.json +++ b/rust/daily_planner/package.json @@ -1,5 +1,5 @@ { - "name": "test", + "name": "daily_planner", "scripts": { "build": "npm run build --workspaces --if-present", "prebuild": "npm run prebuild --workspaces --if-present",