Microsoft Certified: Azure DevOps Engineer Expert
Microsoft Certified: DevOps Engineer Expert

It feels a little odd to be able to call myself an “expert” in anything, and truth be told, I don’t think I would call myself an expert in Azure DevOps (yet), but that’s the name of the certification, so here we are.

So this was the big one. This was the goal I had in mind when I started my certification journey 3 years ago. (Almost to the day!) It was also one of the short-term goals I set for myself in my last promotion panel. It took me longer to get there – mostly because my career has picked up more and more the further I went into this space, so I had less time and energy to dedicate to study. The exam for this was rescheduled no less than eleven times! I’d meant to sit it in Q4 2021, but I was head down on a big and important project at that point and had to reschedule the pre-requisite AZ-204 exam to 2022. After that I just kept postponing it and feeling bad until I stuck a pin in the calendar and said “no more!”

Like most of my exams, I leaned on a mix of MS Learn, MeasureUp, and WhizLabs to prepare. With the benefit of hindsight, I’d recommend the WhizLabs practice tests over MeasureUp. While the general practice of the MeasureUp test was good, the WhizLabs tests were much closer to the questions I was asked in my exam – obviously this might not always be the case, as both Microsoft and the test providers refresh their questions every so often.

Overall I’d say I found the exam itself easier than the Developer Associate, but I had a lot of the same “I don’t really recognise this” feeling all the way through. I think that’s just a general anxiety I have in these exams, as despite the feeling I passed with a generous buffer. As a general aside, there were a lot more questions on the nitty-gritty of Git than I expected; I’d focussed more on GitHub features, Azure Repos branch policies, and other Azure-specific pieces of the SCM puzzle, so it wasn’t as easy to recall some the answers.

But it’s over with. I’ve decided I’m not going to go for the Azure Administrator cert (AZ-104) for now, as I think I need a pause or change of focus for a while to avoid some learning and development burnout.

But that said, some of the stuff announced at MSBuild 23 looks really interesting, so maybe I should build on my AI Fundamentals and learn about all this new AI/Copilot tech 😉

Preamble

I’ve been using bluesky for the last few days now. I quite like it. It has early-Tumblr-crossed-with-Twitter vibes, with plenty of people using the freedom of a small, semi-closed beta to just be weird online.

I finally got around to having a very quick play with the AT protocol, after a skim of the documentation. The good news is that it’s essentially schema’d JSON over HTTPS – which is handy, because that’s essentially what I spent most of last year working on, in a different context. The two parts I’ve focussed on for now are Lexicons (the schema bit) and XPRC (the JSON over HTTPS bit).

I’ve documented my baby steps with the protocol below with a few examples – essentially: authenticate, post a “Hello World!” skeet, then reply to that skeet (no, I don’t know why they’re referred to as “skeets”), and get some author information. I’ve done these initial experiments with Postman/cURL, so that I didn’t have to faff around with setting up any sort of language or framework tooling. After all, it is just HTTPS. Hopefully you can translate them into your tool of choice.


Note – you will need a bluesky account for this to work, which needs an invite if you don’t already have one. Sorry, I can’t help you get one, as I haven’t been given any yet.


All requests are either GET or POST. Methods always use reverse DNS notation as a naming convention, and are under /xprc. The endpoint is of the form https://<server>/xprc/NSID – for example https://bsky.social/xrpc/com.atproto.server.createSession is the endpoint for, well, creating a session. All current methods are listed in the documentation. Some of those listed are low-level methods (com.atproto.*), while others are wrappers over those which should make things easier (app.bsky.*). I couldn’t get some of the higher-level methods to work, so much of what’s below uses those lower-level methods. I guess this reinforces that everything is still a work in progress when it comes to AT protocol.

Without further ado, let’s dive in!

Example 1: Authenticate

Authentication is reasonably straightforward. We POST a JSON object with our identifier (username) and a password, then get a JSON object back with our token and other information. You could use your regular password for playing around like this, but I highly recommend you get in the habit of using an App Password, which can be generated from your bluesky account settings. As a cURL command, it looks something like this:

Request

curl --location 'https://bsky.social/xrpc/com.atproto.server.createSession' \
--header 'Content-Type: application/json' \
--data '{
    "identifier": "chrismcleod.dev",
    "password": "<some-password>"
}'

Response

{
    "did": "did:plc:<identifier>",
    "handle": "chrismcleod.dev",
    "email": "<email>",
    "accessJwt": "<JWT Token>",
    "refreshJwt": "<Refresh Token>"
}

Other than the accessJWT, which you’ll need to pass in future requests for authentication purposes, the most interesting thing in the response is the did property – this is your distributed identifier, and is needed for storing posts in your user repository. Make a note of both the did and accessJwt. (I stored both as variables in Postman, to make things easier)

Example 2: Post

Now we have our authentication token and identifier, we can write a new post to our repository. We’re going to use com.atproto.repo.createRecord to achieve this. To do this, our JSON needs to specify:

  • The collection we’re writing to
  • The repo that repo is located in
  • The record object we are creating

Posts record objects use the app.bsky.feed.post type, so we must declare that in our record object. Other required fields in the Post record are text and createdAt. For Posts, the record $type is also the collection type. The repo is your did from earlier. Put together, the complete payload looks like this:

{
    "collection": "app.bsky.feed.post",
    "repo": "{{myDID}}",
    "record": {
        "text": "Hello World!",
        "createdAt": "{{$isoTimestamp}}",
        "$type": "app.bsky.feed.post"
    }
}
// The values enclosed in {{}} are Postman variables - substitute with your own values, without the curly braces.

There are other properties we can add to our bluesky Post, but I’m not going to get into them in this blog post, other than the reply property which is covered below. Take a look at the docs page linked above for more details.

Request

Putting our payload and authentication token together, we get a cURL command similar to this:

curl --location 'https://bsky.social/xrpc/com.atproto.repo.createRecord' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <token>' \
--data '{
    "collection": "app.bsky.feed.post",
    "repo": "did:plc:<identifier>",
    "record": {
        "text": "A test post made using info from the ATproto docs",
        "createdAt": "2023-05-11T14:06:27.499Z",
        "$type": "app.bsky.feed.post"
    }
}'

Response

The response will contain an at:// protocol URI to your new post, as well as the signature (cid), and look something like this:

{
    "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
    "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu"
}

You will need both of these to make your reply, so copy the entire object from your own response.

Example 3: Reply to our post

Replies are essentially just regular Posts, they just have an extra reply property which pins them to the conversation tree. This property is an object with two properties: root and parent. root is the very first message in the thread – the one everything else is a descendant of. parent is the specific message you are replying to, which might be nested several layers deep. Taking the following example:

  1. Post 1
    • Reply 1
    • Reply 2
      • Reply 3
    • Reply 4

For any reply in this tree, root will always be a reference to Post 1, and parent will be a reference to, e.g. Reply 3, or whichever post you are replying to. Because we are replying to our own Hello World message from above, both root and parent will be references to the same Post. As such, to reply to our own message, our Reply payload would look like this:

{
    "collection": "app.bsky.feed.post",
    "repo": "{{myDID}}",
    "record": {
        "text": "Hello, Hello World",
        "createdAt": "{{$isoTimestamp}}",
        "reply": {
            "root": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu"
            },
            "parent": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu"
            }
        },
        "$type": "app.bsky.feed.post"
    }
}

I’ve highlighted the new part of the message. The full request looks like:

Request

curl --location 'https://bsky.social/xrpc/com.atproto.repo.createRecord' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <token>' \
--data '{
    "collection": "app.bsky.feed.post",
    "repo": "did:plc:<identifier>",
    "record": {
        "text": "Hello, Hello World!",
        "createdAt": "2023-05-11T15:59:08.600Z",
        "reply": {
            "root": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu"
            },
            "parent": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu"
            }
        },
        "$type": "app.bsky.feed.post"
    }
}'

Response

Server responses to Replies are the same as to Posts – an object containing the uri and cid:

{
    "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh33tthzm27",
    "cid": "bafyreifh4xinbvqujmrbc6unyyhhefn4x6dg3cieutk5u5sp4b6c7hmcki"
}

Example 4: Listing User Posts

Now we’re creating posts, how do we list posts by a given user ? Using a GET to /xprc/app.bsky.feed.getAuthorFeed, passing the author handle as as parameter called actor. You still need to use your authentication token as a header. By default you will pull back 50 posts. This can be changed using a limit parameter, with a min of 1 and max of 100:

Request

curl --location 'https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=chrismcleod.dev&limit=5' \
--header 'Authorization: Bearer <token>'

Response

The response includes details of the author’s posts, as well as any posts they were in reply to:

{
    "feed": [
        {
            "post": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvhatmjm6u2z",
                "cid": "bafyreicej3vuls22l5tli5aycnnxebap77vvczc43o73g6px34q6uinibq",
                "author": {
                    "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                    "handle": "chrismcleod.dev",
                    "displayName": "Chris M",
                    "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                    "viewer": {
                        "muted": false,
                        "blockedBy": false
                    },
                    "labels": []
                },
                "record": {
                    "text": "thing is, I'm sure it changes per manufacturer! Last toaster was \"toastiness\", current is time",
                    "$type": "app.bsky.feed.post",
                    "reply": {
                        "root": {
                            "cid": "bafyreie4477tqvjkwffgmwfhcgd4zjuw73xesmpfkcwylucn6xijyfjgha",
                            "uri": "at://did:plc:aeh5xvhpva7ksoialzs4o77y/app.bsky.feed.post/3jvhacxqpqh2n"
                        },
                        "parent": {
                            "cid": "bafyreie4477tqvjkwffgmwfhcgd4zjuw73xesmpfkcwylucn6xijyfjgha",
                            "uri": "at://did:plc:aeh5xvhpva7ksoialzs4o77y/app.bsky.feed.post/3jvhacxqpqh2n"
                        }
                    },
                    "createdAt": "2023-05-11T11:57:59.203Z"
                },
                "replyCount": 0,
                "repostCount": 0,
                "likeCount": 0,
                "indexedAt": "2023-05-11T11:57:59.435Z",
                "viewer": {},
                "labels": []
            },
            "reply": {
                "root": {
                    "uri": "at://did:plc:aeh5xvhpva7ksoialzs4o77y/app.bsky.feed.post/3jvhacxqpqh2n",
                    "cid": "bafyreie4477tqvjkwffgmwfhcgd4zjuw73xesmpfkcwylucn6xijyfjgha",
                    "author": {
                        "did": "did:plc:aeh5xvhpva7ksoialzs4o77y",
                        "handle": "mzbat.bsky.social",
                        "displayName": "bat 🦇",
                        "avatar": "https://cdn.bsky.social/imgproxy/DCZNdq7mkezO6MIIKOkj4JIxpwvr6ihrVD3mQy_1gAY/rs:fill:1000:1000:1:0/plain/bafkreia3rwn5fvkd3vw5pc6aipivac6tfcbk3ma6qjjymxwzsse2dnjuxa@jpeg",
                        "viewer": {
                            "muted": false,
                            "blockedBy": false,
                            "following": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.graph.follow/3justmfnnia2v",
                            "followedBy": "at://did:plc:aeh5xvhpva7ksoialzs4o77y/app.bsky.graph.follow/3jut3rhljvo2m"
                        },
                        "labels": []
                    },
                    "record": {
                        "text": "All this time I thought the number dial on the toaster was the level of toastiness but it’s really just the number of minutes to cook? I’m flabbergasted.",
                        "$type": "app.bsky.feed.post",
                        "createdAt": "2023-05-11T11:48:40.559Z"
                    },
                    "replyCount": 4,
                    "repostCount": 0,
                    "likeCount": 11,
                    "indexedAt": "2023-05-11T11:48:40.795Z",
                    "viewer": {
                        "like": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.like/3jvharu3yuk26"
                    },
                    "labels": []
                },
                "parent": {
                    "uri": "at://did:plc:aeh5xvhpva7ksoialzs4o77y/app.bsky.feed.post/3jvhacxqpqh2n",
                    "cid": "bafyreie4477tqvjkwffgmwfhcgd4zjuw73xesmpfkcwylucn6xijyfjgha",
                    "author": {
                        "did": "did:plc:aeh5xvhpva7ksoialzs4o77y",
                        "handle": "mzbat.bsky.social",
                        "displayName": "bat 🦇",
                        "avatar": "https://cdn.bsky.social/imgproxy/DCZNdq7mkezO6MIIKOkj4JIxpwvr6ihrVD3mQy_1gAY/rs:fill:1000:1000:1:0/plain/bafkreia3rwn5fvkd3vw5pc6aipivac6tfcbk3ma6qjjymxwzsse2dnjuxa@jpeg",
                        "viewer": {
                            "muted": false,
                            "blockedBy": false,
                            "following": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.graph.follow/3justmfnnia2v",
                            "followedBy": "at://did:plc:aeh5xvhpva7ksoialzs4o77y/app.bsky.graph.follow/3jut3rhljvo2m"
                        },
                        "labels": []
                    },
                    "record": {
                        "text": "All this time I thought the number dial on the toaster was the level of toastiness but it’s really just the number of minutes to cook? I’m flabbergasted.",
                        "$type": "app.bsky.feed.post",
                        "createdAt": "2023-05-11T11:48:40.559Z"
                    },
                    "replyCount": 4,
                    "repostCount": 0,
                    "likeCount": 11,
                    "indexedAt": "2023-05-11T11:48:40.795Z",
                    "viewer": {
                        "like": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.like/3jvharu3yuk26"
                    },
                    "labels": []
                }
            }
        },
        {
            "post": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh5cu7lzv2h",
                "cid": "bafyreibambjl4tbpgacmq5kofs6xfgkamjut2vkuql3zpe7dydvf4ykafm",
                "author": {
                    "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                    "handle": "chrismcleod.dev",
                    "displayName": "Chris M",
                    "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                    "viewer": {
                        "muted": false,
                        "blockedBy": false
                    },
                    "labels": []
                },
                "record": {
                    "text": "TBH, I'm kinda glad this didn't work 😂\n\nI didn't expect it to, but if you stick something in the protocol called `createInviteCode`, you better believe I'm going to give it a try!",
                    "$type": "app.bsky.feed.post",
                    "embed": {
                        "$type": "app.bsky.embed.images",
                        "images": [
                            {
                                "alt": "A screenshot of an API call made to the createInviteCode AT protocol endpoint. It was unsuccessful due to lack of authorisation",
                                "image": {
                                    "$type": "blob",
                                    "ref": {
                                        "$link": "bafkreicl4wg7bfqecuy5mvw6ygzgxrz7qll22oxupgsp6v6m6sc2iypuzy"
                                    },
                                    "mimeType": "image/jpeg",
                                    "size": 225657
                                }
                            }
                        ]
                    },
                    "createdAt": "2023-05-11T10:54:55.583Z"
                },
                "embed": {
                    "$type": "app.bsky.embed.images#view",
                    "images": [
                        {
                            "thumb": "https://cdn.bsky.social/imgproxy/Dx4hVByyiOlNRTyDaOHP0lm8nyc2ElLfEcQ5TOSB0BY/rs:fit:1000:1000:1:0/plain/bafkreicl4wg7bfqecuy5mvw6ygzgxrz7qll22oxupgsp6v6m6sc2iypuzy@jpeg",
                            "fullsize": "https://cdn.bsky.social/imgproxy/LQ6Ni5920A63RVdwkUoKZnWM15PjVFcRwz_atFOnOr4/rs:fit:2000:2000:1:0/plain/bafkreicl4wg7bfqecuy5mvw6ygzgxrz7qll22oxupgsp6v6m6sc2iypuzy@jpeg",
                            "alt": "A screenshot of an API call made to the createInviteCode AT protocol endpoint. It was unsuccessful due to lack of authorisation"
                        }
                    ]
                },
                "replyCount": 0,
                "repostCount": 0,
                "likeCount": 2,
                "indexedAt": "2023-05-11T10:54:55.856Z",
                "viewer": {},
                "labels": []
            }
        },
        {
            "post": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh3fd5mrg2x",
                "cid": "bafyreiatii5cpouvemlcdpxbi3ulh2joap2rilqd7dtuvuzt4g5qqz7m3q",
                "author": {
                    "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                    "handle": "chrismcleod.dev",
                    "displayName": "Chris M",
                    "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                    "viewer": {
                        "muted": false,
                        "blockedBy": false
                    },
                    "labels": []
                },
                "record": {
                    "text": "Well that was a fun hour or so of tinkering around with the protocol to figure out the basics. Some of the lexicon is a little verbose for my tastes, better verbose than ambiguous, I guess.\n\nNow to use this knowledge for good/evil",
                    "$type": "app.bsky.feed.post",
                    "embed": {
                        "$type": "app.bsky.embed.images",
                        "images": [
                            {
                                "alt": "",
                                "image": {
                                    "$type": "blob",
                                    "ref": {
                                        "$link": "bafkreick25qjy6l75xlied3b5ozxl3r6xq54xlks5pacud2ububm3aamve"
                                    },
                                    "mimeType": "image/jpeg",
                                    "size": 98914
                                }
                            }
                        ]
                    },
                    "createdAt": "2023-05-11T10:20:30.929Z"
                },
                "embed": {
                    "$type": "app.bsky.embed.images#view",
                    "images": [
                        {
                            "thumb": "https://cdn.bsky.social/imgproxy/9TcZtC0w98I4uQ1DSNBssgw2u1b7fgp4bY7qB78oNxI/rs:fit:1000:1000:1:0/plain/bafkreick25qjy6l75xlied3b5ozxl3r6xq54xlks5pacud2ububm3aamve@jpeg",
                            "fullsize": "https://cdn.bsky.social/imgproxy/6Ph5-LuMWuoS9iV-EFGYDhtkhazlzuHqQMhFeWXs8y8/rs:fit:2000:2000:1:0/plain/bafkreick25qjy6l75xlied3b5ozxl3r6xq54xlks5pacud2ububm3aamve@jpeg",
                            "alt": ""
                        }
                    ]
                },
                "replyCount": 0,
                "repostCount": 0,
                "likeCount": 1,
                "indexedAt": "2023-05-11T10:20:31.409Z",
                "viewer": {},
                "labels": []
            }
        },
        {
            "post": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh33tthzm27",
                "cid": "bafyreifh4xinbvqujmrbc6unyyhhefn4x6dg3cieutk5u5sp4b6c7hmcki",
                "author": {
                    "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                    "handle": "chrismcleod.dev",
                    "displayName": "Chris M",
                    "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                    "viewer": {
                        "muted": false,
                        "blockedBy": false
                    },
                    "labels": []
                },
                "record": {
                    "text": "A test reply made using info from the ATproto docs",
                    "$type": "app.bsky.feed.post",
                    "reply": {
                        "root": {
                            "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu",
                            "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y"
                        },
                        "parent": {
                            "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu",
                            "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y"
                        }
                    },
                    "createdAt": "2023-05-11T10:15:12.698Z"
                },
                "replyCount": 0,
                "repostCount": 0,
                "likeCount": 1,
                "indexedAt": "2023-05-11T10:15:13.161Z",
                "viewer": {
                    "like": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.like/3jvh34asm772t"
                },
                "labels": []
            },
            "reply": {
                "root": {
                    "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                    "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu",
                    "author": {
                        "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                        "handle": "chrismcleod.dev",
                        "displayName": "Chris M",
                        "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                        "viewer": {
                            "muted": false,
                            "blockedBy": false
                        },
                        "labels": []
                    },
                    "record": {
                        "text": "A test post made using info from the ATproto docs",
                        "$type": "app.bsky.feed.post",
                        "createdAt": "2023-05-11T10:09:39.554Z"
                    },
                    "replyCount": 1,
                    "repostCount": 0,
                    "likeCount": 0,
                    "indexedAt": "2023-05-11T10:09:39.975Z",
                    "viewer": {},
                    "labels": []
                },
                "parent": {
                    "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                    "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu",
                    "author": {
                        "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                        "handle": "chrismcleod.dev",
                        "displayName": "Chris M",
                        "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                        "viewer": {
                            "muted": false,
                            "blockedBy": false
                        },
                        "labels": []
                    },
                    "record": {
                        "text": "A test post made using info from the ATproto docs",
                        "$type": "app.bsky.feed.post",
                        "createdAt": "2023-05-11T10:09:39.554Z"
                    },
                    "replyCount": 1,
                    "repostCount": 0,
                    "likeCount": 0,
                    "indexedAt": "2023-05-11T10:09:39.975Z",
                    "viewer": {},
                    "labels": []
                }
            }
        },
        {
            "post": {
                "uri": "at://did:plc:fcewtyqycu5qlt26tnbnan6h/app.bsky.feed.post/3jvh2rw5ua22y",
                "cid": "bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu",
                "author": {
                    "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
                    "handle": "chrismcleod.dev",
                    "displayName": "Chris M",
                    "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
                    "viewer": {
                        "muted": false,
                        "blockedBy": false
                    },
                    "labels": []
                },
                "record": {
                    "text": "A test post made using info from the ATproto docs",
                    "$type": "app.bsky.feed.post",
                    "createdAt": "2023-05-11T10:09:39.554Z"
                },
                "replyCount": 1,
                "repostCount": 0,
                "likeCount": 0,
                "indexedAt": "2023-05-11T10:09:39.975Z",
                "viewer": {},
                "labels": []
            }
        }
    ],
    "cursor": "1683799779554::bafyreibmcnkzbxtciyydktbatjlpgh5hollpov4zvpwtaaq353vyzfbxwu"
}

Example 5 Get a User Profile

OK, last one. To get the profile details of a user, we use a GET to /xprc/app.bsky.actor.getProfile, again passing the user handle as an actor property, and passing our authentication token in the header:

Request

curl --location 'https://bsky.social/xrpc/app.bsky.actor.getProfile?actor=chrismcleod.dev' \
--header 'Authorization: Bearer <token>'

Response

{
    "did": "did:plc:fcewtyqycu5qlt26tnbnan6h",
    "handle": "chrismcleod.dev",
    "displayName": "Chris M",
    "description": "Online since before some of you were born. \nTired.\nLead Software Developer, but I’m not allowed to talk about it.\nHe/him/his. Scotland\n⚠️Potential to post Warhammer content⚠️\n—\nhttps://chrismcleod.dev \nhttp://worldsinminiature.com ",
    "avatar": "https://cdn.bsky.social/imgproxy/42qD9eitqqNjGc7IXG6e13lmN2IH_m9RqUTH1ih-oDQ/rs:fill:1000:1000:1:0/plain/bafkreigc4hh664b524ku2tbkrsubserosqqlj3tsxuqxpg72lbng3xcfay@jpeg",
    "banner": "https://cdn.bsky.social/imgproxy/wNj_cLIyXZOj2m2t_nbehxg4JVAHzixb8iXFG5uNuwA/rs:fill:3000:1000:1:0/plain/bafkreicgnnuuhbz6eb7hcnrisjeyekgexea6pcdxp5uob56zohsp56jzd4@jpeg",
    "followsCount": 140,
    "followersCount": 64,
    "postsCount": 82,
    "indexedAt": "2023-05-09T19:44:05.927Z",
    "viewer": {
        "muted": false,
        "blockedBy": false
    },
    "labels": []
}

Wrapping up

So there we have it – five basic operations with the AT protocol for you to try out. Hopefully you found it useful, interesting, and easy enough to follow along! Have you made any experiments with AT yet? Or are planning to? Let me know the details, or any other feedback you have, in the comments/via webmention/on social media 🙂

An upturned 3D printer build plate, showing a batch of 60 studded Space Marine shoulder pads, in the style of Mk 6 marines from the Horus Heresy: Age of Darkness boxset. They have been printed in a dark grey resin

Added supports to an STL I wanted to print for the first time. It even printed out fine… eventually. The first attempt came out as a near solid brick of resin, and required a very messy cleaning out of the resin vat, to remove parts that had stuck to the bottom. It was just a tiny, very simple thing – a space marine shoulder pad – but: yay! I finally did it!


Context:

For those unfamiliar with (resin) 3D printing: because 3D printing is done layer-by-layer, and resin printers essentially work upside-down, most models require some form of supports added to the model before it will print OK in a resin printer. These act as scaffolding holding an area of the model being in place while any surrounding geometry is cured, preventing “islands” (areas of a model not connected to the rest of the model on that layer) from sticking to the bottom of the resin vat when the build plate is raised. Resin stuck to the bottom of the vat is a very bad thing – 1, your model will be incomplete, if it prints at all, and 2, it can lead to a broken printer. So: supports are important! See below for an example of an excellent, professionally supported model:

A professionally supported commercial STL file, ready to print

I’ve been putting off trying to support models by myself; mistakes take a long time to discover and a massive pain to clean up. Most commercial models come “pre-supported” – a professional has already done the work. But I still have plenty models in my library which are by hobbyists and come unsupported; if I want to print those, I have to learn how to do it, so I’m going to have try. I watched a couple of videos on YouTube to get an idea of what I need to do, then just gave it a shot in Lychee, checking the output in UVTools regularly to make sure I hadn’t missed any unsupported islands.

Despite this checking, and creating a supposedly perfect setup, my first print failed. Badly. I think it was because I didn’t raise the model up high enough from the raft, which combined with the suction force created by the type of raft I chose, compounded three problems – partial peeling away from the build plate, deformation caused by too few supports, and close-together parts fusing together when exposed (in this case the raft, supports, and model). Like I said, it came out like a solid block with bumps where the tops of the pads should be, after 1h 45 minutes in the printer. Much swearing, and spending my lunch break cleaning things up followed.

Going back to the drawing board: I raised the model up, added lots more – but thinner – supports (probably more than I needed), and changed the raft type to one which wouldn’t act like a suction cup. 2h 19 minutes later and I had a very successful print!

An upturned 3D printer build plate, showing a batch of 60 studded Space Marine shoulder pads, in the style of Mk 6  marines from the Horus Heresy: Age of Darkness boxset. They have been printed in a dark grey resin
An amateurly supported STL file, printed 60 times

A screenshot of some Laravel user authentication boilerplate code

The festive break seems to be one of the times I manage to sit down and try something new. This year I’m taking the time to learn a little of the PHP framework Laravel, by way of re-writing an app I made last year with React and Firebase. That app always felt a little fragile to me, even though it succeeded at its basic functions – probably why I haven’t gone back to update it at any point in the last year. In my defence, that app was a learning exercise too, as I wanted to brush up on React for my day job before starting on a project at the start of 2022.

But safe to say, I’m much more comfortable with “the old ways” of PHP. I’ve been writing PHP in one form or another for close to 25 years, and even though I wouldn’t ever call myself an expert due to my on-off usage of it over the last decade, I do still have the basics of the language in my head. I can follow along and debug most code I’ve encountered just fine. PHP 8.x is different enough that I definitely need to follow some tutorials to write it, but there’s still a lot I recognise. Laravel itself has a lot of similar concepts to frameworks I’ve used in the past, it just does them with modern practices and architecture. Again, lots I’m unfamiliar with, but plenty that I recognise.

Apart from making I Painted This! more robust and supportable (in my view), one of the primary drivers for this exercise – other than as a convenient excuse for learning – is that the app primarily uses Twitter for authentication. That’s not something I’m comfortable with any more, so it’s got to go – and if I needed to do that change, I’d be as well giving it a complete overhaul!

A few of us at work might be moving to a project that uses React more heavily than anything we’ve done before, so I wanted to make a list of any interesting links which might be useful to share with the group. Some might not be specifically about React, but were useful for me getting up and running on my home PC, or are worth storing for future reading:

Much like my development environment notes, this post is pretty much just for me – but if you find it useful, I’m glad!

Since last year I’ve made a point that whenever I feel a bit “neurofunky”, I try to do something to invest in myself. The last few days have been a thing so I’ve planned the pathways to my next certification(s), and set myself up with some of the resources I’ll need to get there.

Right now, the plan is to complete the following exams over the next 6 weeks:

  • Azure Data Fundamentals (DP-900)
  • Power Platform Fundamentals (PL-900)
  • Azure AI Fundamentals (AI-900)

At least 2 of those topics are pretty much brand new to me, so it’s going to be an interesting time…

I’ve already booked the exams, to give myself a set timeframe and deadline for each. My employer offers vouchers towards taking these exams, so it doesn’t cost me anything over and above the time and effort investments. Even if the exams hadn’t been free, then I’d have probably booked at least one of these (or maybe more, just spread out over months rather than weeks)

So we’ll see how it goes. I’m excited to learn some new things, but I am conscious I’m going to be under a bit of stress due to the timing.