Skip to content

init#1

Merged
baileydunning merged 56 commits intomainfrom
full-review-base
Sep 18, 2025
Merged

init#1
baileydunning merged 56 commits intomainfrom
full-review-base

Conversation

@baileydunning
Copy link
Copy Markdown
Member

@baileydunning baileydunning commented Sep 3, 2025

This api is responsible for uploading and retrieving optimized images using HarperDB as the backend. Key features include:

  • Upload endpoint (POST /images) for storing original images with format validation.
  • Retrieval endpoint (GET /images) supporting dynamic resizing, format conversion (WebP, AVIF, JPEG), and device pixel ratio.
  • Add update endpoint (PUT /images) for upserting original images and purging stale variants
  • Integrate HarperDB tables (images, image_variants) for storage and caching, as defined in schema.graphql
  • Configuration via config.yaml
  • Main API logic in resources.ts

@baileydunning baileydunning self-assigned this Sep 3, 2025
Copy link
Copy Markdown
Member

@kriszyp kriszyp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of awesome stuff in here, great work!

Comment thread api/resources.ts Outdated

if (Buffer.isBuffer(target.data)) {
bytes = target.data;
} else if (typeof target.data?.arrayBuffer === "function") {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking for a Blob, I presume? Do we have any way that this can currently take place, through HTTP?

Comment thread api/resources.ts
const ImagesTable = databases.ImageOptimization.images;
const VariantsTable = databases.ImageOptimization.image_variants;

export class Images extends Resource {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the signatures of the methods you are using, I think you want this class to have static loadAsInstance = false.

Comment thread api/resources.ts Outdated
}

// Upload and store original image
async post(target: any, data: any) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like it would be pretty intuitive to have a put() method here too, so the user can actually specify the id/path.

And I think these arguments are getting reversed due to the lack static loadAsInstance = false.

Comment thread api/resources.ts Outdated
await ImagesTable.put({
id,
blob: bytes,
contentType: target.headers?.["content-type"] || "application/octet-stream",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the data (the real one, which is confusing because the arguments are reversed), has a direct contentType property. But this is fine too.

Comment thread api/resources.ts Outdated
Comment thread api/resources.ts Outdated
const variantKey = `${id}_${width || 'orig'}_${dpr}_${format}`;

// Try to get variant from database
let variant = await VariantsTable.get(variantKey);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be implemented as a caching table (see https://docs.harperdb.io/docs/developers/applications/caching for docs on this). Manually doing a get and put introduces a lot of race conditions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweet, thank you for the tip

Comment thread api/resources.ts Outdated
Comment thread api/resources.ts Outdated
await sharp(bytes).metadata();
} catch (err: any) {
logger.error("Invalid image format:", err);
return { status: 400, data: { error: "Invalid image format: " + err.message } };
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Response object has to have a status and a headers otherwise, it is interpreted as a plain object to serialize. Errors should be thrown, not returned.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I throw errors in the response, I get 500 status codes for everything. Is there a workaround for this, or a recommended way to implement error handling properly with Harper so client errors (like 400) are returned with the correct status code?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread api/harper.d.ts Outdated
@@ -0,0 +1 @@
declare module 'harperdb'; No newline at end of file
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary for the types? It is not imported, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I was getting errors until I added this bit in. Stolen from the early hints template

Comment thread api/resources.ts Outdated

export class Images extends Resource {
allowRead(user: User) {
return user?.role?.id === 'super_user';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't we typically want to allow broader access to images?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point—this was originally pulled from the early hints template. I think it makes the most sense to restrict access for POST and PUT operations, while allowing broader read access for all image variants. It really depends on the usage of the api though, like if it used for something that allows public uploads we'd want to allow wider access to those endpoints as well. For the sake of this template, do you think we should stick with the secure defaults, or make it more permissive to cover broader use cases?

Comment thread api/resources.ts Outdated
return { status: 400, data: { error: "Invalid image format: " + err.message } };
}

const id = Math.random().toString(36).slice(2, 10);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harper itself can generate the ids with the create method, which uses UUIDs for strings (provides better guarantees of uniqueness), or auto-incrementation for numbers (which is faster).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's cool! Are there reference docs for this / other methods somewhere or can I just import it and call create(id). Im trying to find something about it but don't think I'm searching in the right place

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

@Ethan-Arrowood Ethan-Arrowood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent work on this!

Comment thread .github/workflows/test.yaml Outdated
Comment on lines +56 to +75
- name: Create ImageOptimization database
run: |
curl -u admin:password \
-H "Content-Type: application/json" \
-d '{"operation":"create_database","database":"ImageOptimization"}' \
http://localhost:9925

- name: Create images table
run: |
curl -u admin:password \
-H "Content-Type: application/json" \
-d '{"operation":"create_table","database":"ImageOptimization","table":"images","primary_key":"id"}' \
http://localhost:9925

- name: Create image_variants table
run: |
curl -u admin:password \
-H "Content-Type: application/json" \
-d '{"operation":"create_table","database":"ImageOptimization","table":"image_variants","primary_key":"id"}' \
http://localhost:9925
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these not being created from the graphqlSchema ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I don't think so, it was unable to find the db/tables without this step

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-09-10 at 9 33 20 AM

Seems to work for me 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you need to restart after deploying the app.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried adding a restart step after deploying but am still seeing connectivity issues without those steps to create the db and tables: failed gh action run

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually was able to remove them! I realized I was referencing the wrong tables in the resources file instead of the ones created by the schema so once I switched that was able to remove those steps

Comment thread .github/workflows/test.yaml Outdated
Comment thread api/test/resources.test.js Outdated
Comment thread api/test/resources.test.js Outdated
Comment thread api/test/resources.test.js Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
baileydunning and others added 13 commits September 10, 2025 09:22
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Copy link
Copy Markdown
Member

@Ethan-Arrowood Ethan-Arrowood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing to block on. this is awesome. great work

Comment thread api/harper.d.ts
Comment thread api/test/resources.test.js Outdated
Comment thread api/test/resources.test.js Outdated
Comment thread api/test/resources.test.js Outdated
Comment on lines +37 to +39
if (res.status !== 200) {
console.log('Variant error response:', res.status, await res.text());
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above; I'd rather not mix debug code with test code. Just fail with the assertion

Comment thread api/test/resources.test.js
Comment thread api/test/resources.test.js Outdated
Comment thread api/utils/index.ts Outdated
const format = formatRaw?.toLowerCase() as VariantFormat;

if (!imageId || !/^[a-z0-9_-]+$/i.test(imageId)) return null;
if (!['webp', 'jpeg', 'avif', 'png'].includes(format)) return null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could still use a type assertion function here; just catch the error and return null

  try {
    assertFormat(format);
  } catch (err) {
    return null;
  }
  // Now `format` is typed as `VariantFormat` here

baileydunning and others added 3 commits September 18, 2025 09:12
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
Co-authored-by: Ethan Arrowood <ethan@arrowood.dev>
@baileydunning baileydunning merged commit ed502eb into main Sep 18, 2025
1 check passed
Copy link
Copy Markdown
Member

@kriszyp kriszyp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a couple of minor notes, just FYI, but these are really minor. Great job with all of this! This is an excellent component!

Comment thread api/resources.ts
try {
cached = await VariantsTable.get(cacheKey);
} catch (err) {
logger.error('VariantsTable.get failed:', err);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, Harper should log thrown errors for you, this will likely result in two log entries.

Comment thread api/resources.ts
throw err;
}
if (!image?.blob) {
logger.error('Image not found for imageId:', imageId);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be a little excessive log level since typically 404s are common and easily triggered by users. Also, you can return undefined to signal the entry is not found and Harper will return a 404 (quicker path since it doesn't involve throwing an error), or a return an object with status/header/body. But this will certainly work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants