diff --git a/README.md b/README.md index 9198641..9f951b7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,20 @@ # Your New Harper Fabric App -This is a template for building [Harper](https://www.harper.fast/) applications. You can download this repository as a starting point for building applications with Harper. +This repository is a template for building applications with [Harper](https://www.harper.fast/). This template includes: ++ 2 tables: Owner, Dog ++ 1 computed field: owner_dog_count ++ 1 relationship: Owners → Dogs ++ 1 custom resource: /OwnerHasBreed + +This template is designed to help you quickly learn Harper’s application model and best practices. + +**Explore the project:** +- `config.yaml`: Main application configuration (routes, settings, etc.) +- `schema.graphql`: Defines tables, fields, relationships, and computed attributes. +- `resources.js`: Implements custom resource classes and endpoint logic. +- `web/`: Contains the frontend web application (HTML, JS, CSS). + +For more information about getting started with HarperDB and building applications, see our [getting started guide](https://docs.harperdb.io/docs). ## Installation @@ -17,29 +31,83 @@ Then you can start your app: npm run dev ``` -Test your application works by querying the `/Greeting` endpoint: +Harper will start at: -```sh -curl http://localhost:9926/Greeting +``` +http://localhost:9926/ ``` -You should see the following: +All tables and resources in this project are automatically available when the app starts. Visit http://localhost:9926/ in your browser to explore the web application and interact with the REST API endpoints. -```json -{"greeting":"Hello, world!"} +## Project Structure + +```graphql +. +├── config.yaml # App configuration +├── schema.graphql # Table definitions, computed fields, relationships +├── resources.js # Custom logic for tables + resources +├── web/ # Client-side web app +│ ├── index.html +│ ├── index.js +│ └── styles.css +└── README.md ``` -Navigate to [http://localhost:9926](http://localhost:9926) in a browser and view the functional web application. +## Database Schema -For more information about getting started with HarperDB and building applications, see our [getting started guide](https://docs.harperdb.io/docs). +All tables are defined in schema.graphql. This template includes two tables: Owner and Dog. Harper automatically exposes REST endpoints for both tables: -For more information on Harper Components, see the [Components documentation](https://docs.harperdb.io/docs/reference/components). +```python-repl +GET /Dog +POST /Dog +PUT /Dog +PATCH /Dog +DELETE /Dog +GET /Owner +... etc. +``` -Take a look at the [default configuration](./config.yaml), which specifies how files are handled in your application. +## Example Workflow -The [schema.graphql](./schema.graphql) is the table schema definition. This is the main starting point for defining your database schema, specifying which tables you want and what attributes/fields they should have. +### 1. Create Dog -The [resources.js](./resources.js) provides a template for defining JavaScript resource classes, for customized application logic in your endpoints. +```sh +curl -X POST http://localhost:9926/Dog \ + -H "Content-Type: application/json" \ + -d '{ + "id": "123", + "name": "Willow", + "breed": "Great Pyrenees" + }' +``` + +### 2. Create Owner + +```sh +curl -X POST http://localhost:9926/Owner \ + -H "Content-Type: application/json" \ + -d '{ + "id": "456", + "name": "Bailey", + "dogIds": ["123"] + }' +``` + +### 3. Use the Custom Resource + +```sh +curl "http://localhost:9926/OwnerHasBreed?ownerName=Bailey&breed=Great%20Pyrenees" +``` + +Response: +```json +{ + "statusCode": 200, + "ownerName": "Bailey", + "breed": "Great Pyrenees", + "hasBreed": true +} +``` ## Deployment diff --git a/resources.js b/resources.js index d51dd3e..876129b 100644 --- a/resources.js +++ b/resources.js @@ -1,24 +1,118 @@ -/** Here we can define any JavaScript-based resources and extensions to tables - -export class MyCustomResource extends tables.TableName { - // we can define our own custom POST handler - post(content) { - // do something with the incoming content; - return super.post(content); - } - // or custom GET handler - get() { - // we can modify this resource before returning - return super.get(); - } +import { tables, Resource } from 'harperdb'; + +const OwnerTable = tables.Owner + +// Computed field example +// +// This shows how to implement a JS-backed computed attribute for a table. +// In schema.graphql, Owner has: +// +// owner_dog_count: Int @computed(version: 1) +// +// The JS implementation below runs whenever Owner records are read or written. +// Computed values are not stored; they are generated on demand by Harper. + +OwnerTable.setComputedAttribute('owner_dog_count', (owner) => { + if (!owner || !Array.isArray(owner.dogIds)) { + return 0; + } + return owner.dogIds.length; +}); + +// Example: extending a table +// +// When you want custom behavior on a table-level REST endpoint, you extend +// the table class produced by Harper's table definition. This allows you to +// override GET, POST, PUT, DELETE, etc. while still inheriting all built-in +// behavior from Harper's table implementation. + +export class OwnerResource extends OwnerTable { + static loadAsInstance = false; + + async post(target, data) { + const record = { ...data }; + // Validate all required fields + const missingFields = []; + if (!record.id) missingFields.push('id'); + if (!record.name) missingFields.push('name'); + if (!Array.isArray(record.dogIds)) missingFields.push('dogIds (must be array)'); + + if (missingFields.length > 0) { + return { + status: 400, + message: `Missing or invalid fields: ${missingFields.join(', ')}`, + }; + } + + const created = await OwnerTable.create(record, this); + return created; + } +} + + +// Example: custom resource using relationships +// +// This resource is not tied to a specific table. It demonstrates how to: +// +// 1. Accept query parameters (?ownerName=...&breed=...) +// 2. Query a table using a relationship-aware select structure +// 3. Iterate through owners and their related dogs (Owner → Dogs relationship) +// 4. Return a boolean indicating whether the owner has any dog of the +// specified breed + +export class OwnerHasBreed extends Resource { + static loadAsInstance = false; + + async get(target) { + const { ownerName, breed } = target.get('ownerName') && target.get('breed') + ? { ownerName: target.get('ownerName'), breed: target.get('breed') } + : {}; + + if (!ownerName || !breed) { + console.warn('[OwnerHasBreed] Missing required query parameters:', { ownerName, breed }); + return { + statusCode: 400, + message: "Missing required query parameters: ownerName and breed", + }; + } + + const query = { + select: [ + "id", + "name", + "dogIds", + { + name: "dogs", + select: ["id", "name", "breed"], + }, + ], + conditions: [{ attribute: "name", comparator: "eq", value: ownerName }], + limit: 1, + }; + + let owner = null; + for await (const o of OwnerTable.search(query)) { + owner = o; + } + + // No owner found → 404 + if (!owner) { + console.warn('[OwnerHasBreed] No owner found for name:', ownerName); + return { + statusCode: 404, + message: `No owner found with name "${ownerName}"`, + }; + } + + const dogs = Array.isArray(owner.dogs) ? owner.dogs : []; + const hasBreed = dogs.some((dog) => dog && dog.breed === breed); + + return { + statusCode: 200, + ownerName, + breed, + hasBreed, + }; + } } - */ -// we can also define a custom resource without a specific table -export class Greeting extends Resource { - // a "Hello, world!" handler - static loadAsInstance = false; // use the updated/newer Resource API - - get() { - return { greeting: 'Hello, world!' }; - } -} \ No newline at end of file + diff --git a/schema.graphql b/schema.graphql index 5ad5d48..8c6ad68 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,7 +1,24 @@ ## Here we can define any tables in our database. This example shows how we define a type as a table using ## the type name as the table name and specifying it is an "export" available in the REST and other external protocols. -type TableName @table @export { - id: ID @primaryKey # Here we define primary key (must be one) - name: String # we can define any other attributes here - tag: String @indexed # we can specify any attributes that should be indexed +## This example uses Owners and Dogs, with a relationship and a computed value. + +type Owner @table(database: "application") @export { + id: ID @primaryKey # Here we define primary key (must be one) + name: String @indexed # we can specify any attributes that should be indexed + dogIds: [ID] @indexed + + # Computed: how many dogs this owner has, based on dogIds. + owner_dog_count: Int @computed(version: 1) + + # Relationship: resolve dogIds into Dog records. + dogs: [Dog] @relationship(from: dogIds) +} + +type Dog @table(database: "application") @export { + id: ID @primaryKey + name: String! @indexed + breed: String! @indexed + + # Relationship back to Owner using the dogIds on Owner. + owner: [Owner] @relationship(to: dogIds) } diff --git a/web/hdb-logo.png b/web/hdb-logo.png new file mode 100644 index 0000000..4ec9611 Binary files /dev/null and b/web/hdb-logo.png differ diff --git a/web/index.html b/web/index.html index f801ee9..475c51f 100644 --- a/web/index.html +++ b/web/index.html @@ -3,26 +3,115 @@ - Harper Application Template + Harper Owners & Dogs -
-

Harper Application Template

-

- This is an example of serving static assets using the built-in - static component.
- You can use this component to serve web applications! -

-
-

0

- - -
-
+
+
+ +
+

Harper Owners & Dogs

+

Interact with your Harper endpoints from the browser.

+
+
+ +
+ +
+

Table API: Owner & Dog

+

Send requests to /Owner or /Dog.

+ +
+ Resource +
+ + +
+
+ +
+ Method +
+ + + + + +
+
+ +
+ + + + + +
+
+ + +
+

Custom Resource: OwnerHasBreed

+

+ Sends GET /OwnerHasBreed?ownerName=...&breed=.... +

+ +
+ + + +
+ +
+ No check performed yet. +
+
+ + +
+

Latest API Response

+

Shows the last JSON response from any request.

+
// Ready. Submit a request above.
+
+
+ + +
diff --git a/web/index.js b/web/index.js index 0a48a84..cdf0c15 100644 --- a/web/index.js +++ b/web/index.js @@ -1,18 +1,156 @@ -let count = 0; -const counterDisplay = document.getElementById('count'); -const decrementButton = document.getElementById('decrement'); -const incrementButton = document.getElementById('increment'); +// Elements +const apiOutput = document.getElementById('api-output'); +const breedResult = document.getElementById('breed-result'); -function updateDisplay() { - counterDisplay.textContent = count; +const tableForm = document.getElementById('table-form'); +const tableIdInput = document.getElementById('table-id'); +const tableBodyInput = document.getElementById('table-body'); +const tableSubmitButton = document.getElementById('table-submit'); + +const resourceToggle = document.getElementById('resource-toggle'); +const methodToggle = document.getElementById('method-toggle'); + +const breedForm = document.getElementById('breed-form'); +const breedOwnerInput = document.getElementById('breed-owner-name'); +const breedNameInput = document.getElementById('breed-name'); +const breedSubmitButton = document.getElementById('breed-submit'); + +// State +let currentResource = 'Owner'; +let currentMethod = 'GET'; + +// Helpers +function showResponse(label, data, isError = false) { + const time = new Date().toLocaleTimeString(); + apiOutput.textContent = + `${label} (${time})${isError ? ' [ERROR]' : ''}\n\n` + + JSON.stringify(data, null, 2); +} + +function updateBreedBadge(result) { + if (!result || typeof result.hasBreed === 'undefined') { + breedResult.className = 'badge badge-muted'; + breedResult.textContent = 'No check performed yet.'; + return; + } + + if (result.hasBreed) { + breedResult.className = 'badge badge-success'; + breedResult.textContent = `${result.ownerName} has a dog of breed "${result.breed}".`; + } else { + breedResult.className = 'badge badge-error'; + breedResult.textContent = `${result.ownerName} does not have a dog of breed "${result.breed}".`; + } } -decrementButton.addEventListener('click', () => { - count--; - updateDisplay(); +async function sendRequest(path, method, body, button, label) { + const original = button.textContent; + button.disabled = true; + button.textContent = 'Working...'; + + const options = { method }; + + if (body !== undefined) { + options.headers = { 'content-type': 'application/json' }; + options.body = JSON.stringify(body); + } + + try { + const res = await fetch(path, options); + const data = await res.json().catch(() => ({})); + showResponse(`${label} ${method} ${path}`, data, !res.ok); + return { ok: res.ok, data }; + } catch (err) { + showResponse(`${label} ${method} ${path}`, { message: err.message }, true); + return { ok: false, data: null }; + } finally { + button.disabled = false; + button.textContent = original; + } +} + +// Toggle handling +resourceToggle.addEventListener('click', (event) => { + if (!(event.target instanceof HTMLElement)) return; + const value = event.target.getAttribute('data-resource'); + if (!value) return; + + currentResource = value; + Array.from(resourceToggle.querySelectorAll('.toggle')).forEach((btn) => + btn.classList.toggle('active', btn.getAttribute('data-resource') === value) + ); +}); + +methodToggle.addEventListener('click', (event) => { + if (!(event.target instanceof HTMLElement)) return; + const value = event.target.getAttribute('data-method'); + if (!value) return; + + currentMethod = value; + Array.from(methodToggle.querySelectorAll('.toggle')).forEach((btn) => + btn.classList.toggle('active', btn.getAttribute('data-method') === value) + ); +}); + +// Table form +tableForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const id = tableIdInput.value.trim(); + const rawBody = tableBodyInput.value.trim(); + + // Route to /Dog or /Owner for default table handlers + let basePath; + if (currentResource === 'Owner') { + basePath = '/Owner/'; + } else if (currentResource === 'Dog') { + basePath = '/Dog/'; + } else { + basePath = `/${currentResource}/`; + } + const path = id ? `${basePath}${encodeURIComponent(id)}` : basePath; + + let body; + const hasBody = ['POST', 'PUT', 'PATCH'].includes(currentMethod); + + if (hasBody) { + if (rawBody) { + try { + body = JSON.parse(rawBody); + } catch (err) { + showResponse('Invalid JSON body', { message: err.message }, true); + return; + } + } else { + body = {}; + } + } + + sendRequest(path, currentMethod, hasBody ? body : undefined, tableSubmitButton, 'Table API'); +}); + +// OwnerHasBreed form +breedForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const ownerName = breedOwnerInput.value.trim(); + const breed = breedNameInput.value.trim(); + + const url = new URL('/OwnerHasBreed', window.location.origin); + if (ownerName) url.searchParams.set('ownerName', ownerName); + if (breed) url.searchParams.set('breed', breed); + + sendRequest(url.toString(), 'GET', undefined, breedSubmitButton, 'OwnerHasBreed') + .then((result) => { + if (!result) return; + if (!result.ok) { + updateBreedBadge(null); + } else { + updateBreedBadge(result.data); + } + }); }); -incrementButton.addEventListener('click', () => { - count++; - updateDisplay(); -}); \ No newline at end of file +// Initial state +showResponse('Ready', { message: 'Use the controls above to talk to your Harper API.' }); +updateBreedBadge(null); diff --git a/web/styles.css b/web/styles.css index d533e9f..54f2ad1 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,57 +1,167 @@ +:root { + --bg: #120224; + --bg-card: #1b0534; + --border: #3e245f; + --purple: #a855f7; + --purple-soft: rgba(168, 85, 247, 0.15); + --purple-strong: #7c3aed; + --purple-light: #e9d5ff; + --text: #f9f9ff; + --text-muted: #c8b5ff; + --success: #4ade80; + --danger: #f87171; + --radius: 12px; + --font: system-ui, -apple-system, Inter, sans-serif; +} + +* { + box-sizing: border-box; +} + body { - font-family: Arial, sans-serif; + margin: 0; + font-family: var(--font); + background: radial-gradient(circle at top, #4c1d95 0, #0d0017 45%, #000 100%); + color: var(--text); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ + +.header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + background: rgba(16, 0, 32, 0.9); +} + +.logo { + width: 38px; + height: 38px; + background: radial-gradient(circle, #d946ef, #a855f7); + border-radius: 50%; display: flex; justify-content: center; align-items: center; - height: 100vh; + font-weight: 800; + color: #17002a; + font-size: 1.3rem; +} + +.header h1 { margin: 0; - background-color: #f5f5f5; + font-size: 1.2rem; } -main { - text-align: center; - background-color: white; - padding: 2rem; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +.header p { + margin: 0.15rem 0 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Layout */ + +.grid { + flex: 1; + max-width: 960px; + margin: 1.5rem auto 2rem; + padding: 0 1.25rem; + display: grid; + gap: 1.3rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.1rem 1.25rem; } -.count { - font-size: 4rem; - margin: 1rem 0; - color: #333; +.card-wide { + grid-column: 1 / -1; } -.counter-controls { +h2 { + margin: 0 0 0.5rem; +} + +.hint { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 0.8rem; +} + +/* Toggles */ + +.toggle-row { display: flex; - justify-content: center; - gap: 1rem; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.7rem; } -button { - font-size: 1.5rem; - width: 3rem; - height: 3rem; - border: none; - border-radius: 50%; +.toggle-row .label { + font-size: 0.85rem; + color: var(--text-muted); +} + +.toggle-group { + display: flex; + gap: 0.35rem; +} + +.toggle { + border: 1px solid var(--border); + border-radius: 999px; + background: #10001e; + color: var(--text-muted); + padding: 0.25rem 0.6rem; + font-size: 0.8rem; cursor: pointer; - transition: background-color 0.2s; } -.decrement { - background-color: #ff6b6b; - color: white; +.toggle.active { + background: var(--purple-soft); + color: var(--purple-light); + border-color: var(--purple); } -.decrement:hover { - background-color: #ff5252; +/* Forms */ + +.form { + display: flex; + flex-direction: column; + gap: 0.7rem; } -.increment { - background-color: #4ecdc4; - color: white; +label { + font-size: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.25rem; } -.increment:hover { - background-color: #39b2a9; +input, +textarea { + padding: 0.45rem 0.6rem; + background: #10001e; + color: var(--text); + border-radius: 8px; + border: 1px solid var(--border); + font-family: var(--font); + font-size: 0.9rem; + resize: vertical; } + +input:focus, +textarea:focus { + outline: none;