Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,58 @@ pnpm run build:ds
pnpm run build
```

## Features

### Remote URL Import with Relative References

Studio supports importing AsyncAPI files from remote URLs with automatic resolution of relative `$ref` references:

- Import files from any URL (e.g., GitHub raw URLs, public APIs)
- The parser automatically resolves relative references using the remote URL as base path
- Example: A file at `https://example.com/specs/api.yaml` referencing `../schemas/user.json` resolves to `https://example.com/schemas/user.json`

### Local Folder Access for Reference Resolution

Studio can resolve local file references (e.g., `$ref: './schema.avsc'`) by requesting folder access:

**Workflow:**
1. Click **Import** → **Open Folder**
2. Select the root folder containing your AsyncAPI files and schemas
3. Select the main AsyncAPI file within that folder
4. The parser automatically resolves all relative file references

**Supported reference formats:**
- `./schema.avsc` - Same directory as the AsyncAPI file
- `../common/types.yaml` - Parent directory
- `apis/avro/schema.avsc` - Subdirectory path

**Supported schema formats:**
- Avro `.avsc` files
- JSON Schema `.json` files
- YAML schema `.yaml` files

**Browser compatibility:**
- ✅ Chrome, Edge, Brave (File System Access API supported)
- ❌ Firefox, Safari (not supported)

**Security note:** Folder access is granted per session only and is not persisted. You must grant access each time you open the application.

### Schema Editing

- Edit both the main AsyncAPI document and referenced schema files
- Changes to referenced schemas are automatically reflected when the parser re-validates
- Real-time validation across all files

### File Saving

- Files opened from a folder (using **Open Folder**) can be saved to their original location with the **Save** button
- For other files, the **Save** button behaves as **Save As**, allowing you to export the current editor content to a selected local file

### Additional Viewers

- **Markdown Preview**: View documentation files with full Markdown rendering, including Mermaid diagrams
- **Avro Schema Viewer**: Visualize Avro schemas with automatically generated Mermaid diagrams

## Architecture decision records

### Create a new architecture decision record
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"js-yaml": "^4.1.1",
"monaco-editor": "0.34.1",
"monaco-yaml": "4.0.2",
"mermaid": "^11.13.0",
"next": "14.2.35",
"postcss": "8.4.31",
"react": "18.2.0",
Expand Down Expand Up @@ -120,6 +121,7 @@
"@types/node": "^18.11.9",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"@types/wicg-file-system-access": "^2023.10.7",
"assert": "^2.0.0",
"autoprefixer": "^10.4.13",
"browserify-zlib": "^0.2.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/components/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const Content: FunctionComponent<ContentProps> = () => { // eslint-disabl
const navigationEnabled = show.primarySidebar;
const editorEnabled = show.primaryPanel;
const viewEnabled = show.secondaryPanel;
const viewType = secondaryPanelType;
const viewType = secondaryPanelType === 'avro' ? 'template' : secondaryPanelType;

const splitPosLeft = 'splitPos:left';
const splitPosRight = 'splitPos:right';
Expand Down
6 changes: 3 additions & 3 deletions apps/studio/src/components/Editor/ConvertDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ import { useDocumentsState, useFilesState } from '../../state';
export const ConvertDropdown: React.FC = () => {
const { editorSvc } = useServices();
const isInvalidDocument = !useDocumentsState(state =>
state.documents['asyncapi'].valid
state.documents['asyncapi']?.valid
);
const language = useFilesState(state => state.files['asyncapi'].language);

return (
<Dropdown
opener={
<Tooltip content="Convert" placement="top" hideOnClick={true}>
<button className="bg-inherit">
<div className="bg-inherit">
<FaFileExport />
</button>
</div>
</Tooltip>
}
buttonHoverClassName="text-gray-500 hover:text-white"
Expand Down
78 changes: 29 additions & 49 deletions apps/studio/src/components/Editor/EditorDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GeneratorModal,
ConvertModal,
ImportUUIDModal,
OpenFolderModal,
} from '../Modals';
import { Dropdown } from '../common';

Expand All @@ -20,9 +21,10 @@ interface EditorDropdownProps {}
export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () => {
const { editorSvc } = useServices();
const isInvalidDocument = !useDocumentsState(state => {
return state.documents['asyncapi'].valid
return state.documents['asyncapi']?.valid
});
const language = useFilesState(state => state.files['asyncapi'].language);
const file = useFilesState(state => state.files['asyncapi']);
const language = file.language;

const importUrlButton = (
<button
Expand All @@ -46,14 +48,17 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
</button>
);

const fileInputRef = React.useRef<HTMLInputElement>(null);

const importFileButton = (
<label
className="block px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer"
title="Import File"
>
<input
ref={fileInputRef}
type="file"
accept='.yaml, .yml, .json'
accept='.yaml, .yml, .json, .avsc'
style={{ position: 'fixed', top: '-100em' }}
onChange={event => {
toast.promise(editorSvc.importFile(event.target.files), {
Expand All @@ -73,12 +78,25 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
</div>
),
});
// Reset so the same file can be re-imported
if (fileInputRef.current) fileInputRef.current.value = '';
}}
/>
Import File
</label>
);

const openFolderButton = (
<button
type="button"
className="px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150"
title="Open Folder"
onClick={() => show(OpenFolderModal)}
>
Open Folder
</button>
);

const importBase64Button = (
<button
type="button"
Expand Down Expand Up @@ -106,12 +124,10 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
<button
type="button"
className="px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150 disabled:cursor-not-allowed"
title={`Save as ${language === 'yaml' ? 'YAML' : 'JSON'}`}
title="Save"
onClick={() => {
toast.promise(
language === 'yaml'
? editorSvc.saveAsYaml()
: editorSvc.saveAsJSON(),
editorSvc.saveCurrentFile(),
{
loading: 'Saving...',
success: (
Expand All @@ -131,46 +147,9 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
},
);
}}
disabled={isInvalidDocument}
disabled={!file.modified}
>
Save as {language === 'yaml' ? 'YAML' : 'JSON'}
</button>
);

const convertLangAndSaveButton = (
<button
type="button"
className="px-4 py-1 w-full text-left text-sm rounded-md focus:outline-none transition ease-in-out duration-150 disabled:cursor-not-allowed"
title={`Convert and save as ${
language === 'yaml' ? 'JSON' : 'YAML'
}`}
onClick={() => {
toast.promise(
language === 'yaml'
? editorSvc.saveAsJSON()
: editorSvc.saveAsYaml(),
{
loading: 'Saving...',
success: (
<div>
<span className="block text-bold">
Document succesfully converted and saved!
</span>
</div>
),
error: (
<div>
<span className="block text-bold text-red-400">
Failed to convert and save document.
</span>
</div>
),
},
);
}}
disabled={isInvalidDocument}
>
Convert and save as {language === 'yaml' ? 'JSON' : 'YAML'}
Save
</button>
);

Expand Down Expand Up @@ -255,6 +234,9 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
<li className="hover:bg-gray-900">
{importUrlButton}
</li>
<li className="hover:bg-gray-900">
{openFolderButton}
</li>
<li className="hover:bg-gray-900">
{importFileButton}
</li>
Expand All @@ -279,9 +261,6 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
<li className="hover:bg-gray-900">
{saveFileButton}
</li>
<li className="hover:bg-gray-900">
{convertLangAndSaveButton}
</li>
</div>
<div>
<li className="hover:bg-gray-900">
Expand All @@ -295,3 +274,4 @@ export const EditorDropdown: React.FunctionComponent<EditorDropdownProps> = () =
</Dropdown>
);
};

13 changes: 9 additions & 4 deletions apps/studio/src/components/Editor/EditorSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,30 @@ import { useFilesState } from '../../state';
import { ShareButton } from './ShareButton';
import { ImportDropdown } from './ImportDropdown';
import { GenerateDropdown } from './GenerateDropdown';
import { SaveDropdown } from './SaveDropdown';
import { SaveButton } from './SaveDropdown';
import { ConvertDropdown } from './ConvertDropdown';

interface EditorSidebarProps {}

export const EditorSidebar: React.FunctionComponent<
EditorSidebarProps
> = () => {
const { source, from } = useFilesState((state) => state.files['asyncapi']);
const { source, from, localPath, uri } = useFilesState((state) => state.files['asyncapi']);

let documentFromText = '';
if (from === 'storage') {
documentFromText = 'From localStorage';
} else if (from === 'file') {
const path = source || localPath || uri;
documentFromText = path ? `From local folder: ${path}` : 'From local folder';
} else if (from === 'url') {
documentFromText = `From URL ${source || uri || ''}`.trim();
} else if (from === 'base64') {
documentFromText = 'From Base64';
} else if (from === 'share') {
documentFromText = 'From Shared';
} else {
documentFromText = `From URL ${source}`;
documentFromText = source || uri ? `From ${source || uri}` : 'From unknown source';
}

return (
Expand All @@ -49,7 +54,7 @@ export const EditorSidebar: React.FunctionComponent<
<GenerateDropdown />
</li>
<li>
<SaveDropdown />
<SaveButton />
</li>
<li>
<ConvertDropdown />
Expand Down
9 changes: 5 additions & 4 deletions apps/studio/src/components/Editor/GenerateDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ import { useServices } from '@/services';

export const GenerateDropdown: React.FC = () => {
const isInvalidDocument = !useDocumentsState(state =>
state.documents['asyncapi'].valid
state.documents['asyncapi']?.valid
);
const { editorSvc } = useServices();

return (
<Dropdown
opener={
<Tooltip content="Generate" placement="top" hideOnClick={true}>
<button className="bg-inherit">
<div className="bg-inherit">
<FaCode />
</button>
</div>
</Tooltip>
}
buttonHoverClassName="text-gray-500 hover:text-white"
Expand Down Expand Up @@ -65,4 +65,5 @@ export const GenerateDropdown: React.FC = () => {
</ul>
</Dropdown>
);
};
};

Loading
Loading